diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index e3d01d1539..d7a332ca5c 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -767,3 +767,8 @@ jobs: go test -race -v -run '^(TestExplicitLocalRepositoryWindowsPathSemantics|TestListDirectory.*|TestDriveRootsFromMask|TestSplitAbsForRoot)$' ./internal/task/service + + # Pure GOOS-branch logic (no syscalls), but it must actually observe + # Windows to prove the unavailable branch, not just compile under it. + - name: Test Windows agent-survival flag availability + run: go test -race -v -run '^TestAgentSurvivalAvailabilityMatchesPlatformScope$' ./internal/runtimeflags diff --git a/apps/backend/cmd/agentctl/diagnostic_log.go b/apps/backend/cmd/agentctl/diagnostic_log.go new file mode 100644 index 0000000000..a6370edcc9 --- /dev/null +++ b/apps/backend/cmd/agentctl/diagnostic_log.go @@ -0,0 +1,54 @@ +package main + +import ( + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/common/logger" +) + +// F71 bound values for agentctl's own detached diagnostic log +// (AC-EXECUTORS-SURVIVAL-001.3): fixed implementation constants, not +// operator settings -- the sink adds no configuration key and no +// environment variable of its own. Chosen to mirror the backend's own +// logger's order of magnitude while staying a visibly distinct budget. +const ( + diagnosticLogMaxSizeMB = 16 + diagnosticLogMaxBackups = 10 + diagnosticLogMaxAgeDays = 14 +) + +// diagnosticLoggingConfig builds the file-backed, bounded logging +// configuration for agentctl's own diagnostic output while it may be +// running detached from any backend. Not yet wired into runMain's logger +// construction: switching away from stdout is a decision that depends on +// whether the agent-survival capability is engaged for this launch, which +// Layer 5.9 threads into agentctl's config (kill-path #6, "inherited +// stdout", is only a problem once that capability is active). This function +// exists so 5.9 has a ready, already-tested LoggingConfig to switch to +// instead of hand-assembling one at the call site. +func diagnosticLoggingConfig(level, format, diagnosticLogPath string) logger.LoggingConfig { + return logger.LoggingConfig{ + Level: level, + Format: format, + OutputPath: diagnosticLogPath, + MaxSizeMB: diagnosticLogMaxSizeMB, + MaxBackups: diagnosticLogMaxBackups, + MaxAgeDays: diagnosticLogMaxAgeDays, + } +} + +// resolveRunLoggingConfig is Layer 5.9's kill-path #6 gate ("inherited +// stdout", design 01's kill paths): stdout is only a liveness hazard once +// the agent-survival capability is actually engaged for this launch, so +// every other case -- capability disabled, or a diagnostic log path that +// failed to resolve for some reason -- keeps today's stdout logging +// unchanged rather than risk an unwritable sink. +func resolveRunLoggingConfig(cfg *config.Config) logger.LoggingConfig { + if cfg.AgentSurvivalEnabled && cfg.DiagnosticLogPath != "" { + return diagnosticLoggingConfig(cfg.LogLevel, cfg.LogFormat, cfg.DiagnosticLogPath) + } + return logger.LoggingConfig{ + Level: cfg.LogLevel, + Format: cfg.LogFormat, + OutputPath: "stdout", + } +} diff --git a/apps/backend/cmd/agentctl/diagnostic_log_test.go b/apps/backend/cmd/agentctl/diagnostic_log_test.go new file mode 100644 index 0000000000..53b9f61a2b --- /dev/null +++ b/apps/backend/cmd/agentctl/diagnostic_log_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/common/logger" +) + +// TestDiagnosticLoggingConfigAppliesTheF71Bounds pins the fixed bound values +// Build picked for AC-EXECUTORS-SURVIVAL-001.3 (MaxSizeMB=16, MaxBackups=10, +// MaxAgeDays=14) and that the level/format/path passed through are used +// verbatim, not silently overridden. +func TestDiagnosticLoggingConfigAppliesTheF71Bounds(t *testing.T) { + got := diagnosticLoggingConfig("debug", "json", "/home/kandev-test/.kandev/logs/agentctl-diagnostic.log") + + want := logger.LoggingConfig{ + Level: "debug", + Format: "json", + OutputPath: "/home/kandev-test/.kandev/logs/agentctl-diagnostic.log", + MaxSizeMB: 16, + MaxBackups: 10, + MaxAgeDays: 14, + } + if got != want { + t.Fatalf("diagnosticLoggingConfig() = %+v, want %+v", got, want) + } +} + +// TestDiagnosticLoggingConfigWritesToADurableFile proves the wire contract +// end to end: constructing a real *logger.Logger from diagnosticLoggingConfig +// and writing a line actually lands in the named file, rather than a +// hand-fabricated fixture asserting only on the struct fields. +func TestDiagnosticLoggingConfigWritesToADurableFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "logs", "agentctl-diagnostic.log") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + log, err := logger.NewLogger(diagnosticLoggingConfig("info", "json", path)) + if err != nil { + t.Fatalf("NewLogger: %v", err) + } + log.Info("agentctl running detached") + if err := log.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q): %v", path, err) + } + if !strings.Contains(string(contents), "agentctl running detached") { + t.Fatalf("diagnostic log file contents = %q, want it to contain the logged message", contents) + } +} + +// TestResolveRunLoggingConfigStaysOnStdoutUnlessSurvivalIsEngaged pins the +// Layer 5.9 gate: kill-path #6 ("inherited stdout", see design 01's kill +// paths) is only a problem once the capability is actually active for this +// launch, so every other case -- disabled, or a diagnostic log path that +// somehow failed to resolve -- must keep today's stdout behavior byte-for- +// byte rather than silently switching sinks. +func TestResolveRunLoggingConfigStaysOnStdoutUnlessSurvivalIsEngaged(t *testing.T) { + cases := []struct { + name string + agentSurvivalEnabled bool + diagnosticLogPath string + wantStdout bool + }{ + {"disabled with a resolved path", false, "/home/kandev-test/.kandev/logs/agentctl-diagnostic.log", true}, + {"enabled but path unresolved", true, "", true}, + {"enabled with a resolved path", true, "/home/kandev-test/.kandev/logs/agentctl-diagnostic.log", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := &config.Config{ + LogLevel: "debug", + LogFormat: "json", + AgentSurvivalEnabled: tc.agentSurvivalEnabled, + DiagnosticLogPath: tc.diagnosticLogPath, + } + got := resolveRunLoggingConfig(cfg) + if tc.wantStdout { + want := logger.LoggingConfig{Level: "debug", Format: "json", OutputPath: "stdout"} + if got != want { + t.Fatalf("resolveRunLoggingConfig() = %+v, want %+v", got, want) + } + return + } + want := diagnosticLoggingConfig("debug", "json", tc.diagnosticLogPath) + if got != want { + t.Fatalf("resolveRunLoggingConfig() = %+v, want %+v", got, want) + } + }) + } +} diff --git a/apps/backend/cmd/agentctl/main.go b/apps/backend/cmd/agentctl/main.go index 10daca35d9..dad382ce76 100644 --- a/apps/backend/cmd/agentctl/main.go +++ b/apps/backend/cmd/agentctl/main.go @@ -109,12 +109,10 @@ func runMain() int { cfg.Port = *portFlag } + applySIGPIPEDisposition(cfg, signal.Ignore) + // Initialize logger - log, err := logger.NewLogger(logger.LoggingConfig{ - Level: cfg.LogLevel, - Format: cfg.LogFormat, - OutputPath: "stdout", - }) + log, err := logger.NewLogger(resolveRunLoggingConfig(cfg)) if err != nil { fmt.Fprintf(os.Stderr, "failed to initialize logger: %v\n", err) return 1 @@ -206,6 +204,13 @@ func run(cfg *config.Config, log *logger.Logger) { // Create instance manager instMgr := instance.NewManager(cfg, log) + // Declared here and assigned below, before any instance can actually be + // created: the server factory closure reads controlServer.CredentialSource() + // lazily (at CreateInstance time, not at SetServerFactory time), so the + // forward reference is safe as long as controlServer is assigned before + // the first instance is created. + var controlServer *api.ControlServer + // Set the server factory to create API servers for each instance instMgr.SetServerFactory(func(instCfg *config.InstanceConfig, procMgr *process.Manager, instLog *logger.Logger) http.Handler { // Create MCP backend client for bidirectional communication through agent stream @@ -225,11 +230,15 @@ func run(cfg *config.Config, log *logger.Logger) { instLog.Info("MCP server enabled (channel-based)", zap.String("session_id", instCfg.SessionID)) - return api.NewServer(instCfg, procMgr, mcpSrv, mcpBackendClient, instLog).Router() + srv := api.NewServer(instCfg, procMgr, mcpSrv, mcpBackendClient, instLog) + srv.SetCredentialSource(controlServer.CredentialSource()) + return srv.Router() }) // Create control server - controlServer := api.NewControlServer(cfg, instMgr, log) + controlServer = api.NewControlServer(cfg, instMgr, log) + stopUnownedReaper := startUnownedReaperIfEnabled(cfg, controlServer) + defer stopUnownedReaper() // Start HTTP server. When no auth token is configured (auth disabled), // ListenHost binds to loopback only so the unauthenticated command/shell/ @@ -261,8 +270,9 @@ func run(cfg *config.Config, log *logger.Logger) { // Returns a channel that closes when the parent dies. parentDied := monitorParentLiveness(log) - // Wait for shutdown signal (OS signal or parent death) - waitForShutdown(log, parentDied, func(ctx context.Context) { + // Wait for shutdown signal (OS signal, parent death, or an authenticated + // ownership-shutdown operation from an adopting/owning backend). + waitForShutdown(log, parentDied, controlServer.ShutdownRequested(), func(ctx context.Context) { // Flush pending traces before stopping instances if err := shared.ShutdownTracing(ctx); err != nil { log.Error("error shutting down tracing", zap.Error(err)) @@ -280,23 +290,24 @@ func run(cfg *config.Config, log *logger.Logger) { }) } -// waitForShutdown waits for a shutdown trigger (OS signal or parent death) and -// runs the cleanup function. parentDied may be nil when no parent monitor is active. -func waitForShutdown(log *logger.Logger, parentDied <-chan struct{}, cleanup func(ctx context.Context)) { +// waitForShutdown waits for a shutdown trigger (OS signal, parent death, or +// an ownership-shutdown operation) and runs the cleanup function. parentDied +// may be nil when no parent monitor is active. ownershipShutdown may be nil +// in tests that don't exercise a control server. +func waitForShutdown(log *logger.Logger, parentDied <-chan struct{}, ownershipShutdown <-chan struct{}, cleanup func(ctx context.Context)) { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - if parentDied == nil { - // No parent monitor — wait for OS signal only. - sig := <-sigCh + // A nil channel (no parent monitor, no control server) blocks forever in + // a select, so that case is simply never chosen -- no separate branching + // needed for the "not active" cases. + select { + case sig := <-sigCh: log.Info("received signal", zap.String("signal", sig.String())) - } else { - select { - case sig := <-sigCh: - log.Info("received signal", zap.String("signal", sig.String())) - case <-parentDied: - log.Warn("parent process died, initiating shutdown") - } + case <-parentDied: + log.Warn("parent process died, initiating shutdown") + case <-ownershipShutdown: + log.Warn("ownership-shutdown operation invoked, initiating shutdown") } log.Info("shutting down agentctl...") diff --git a/apps/backend/cmd/agentctl/sigpipe.go b/apps/backend/cmd/agentctl/sigpipe.go new file mode 100644 index 0000000000..cec41bcb48 --- /dev/null +++ b/apps/backend/cmd/agentctl/sigpipe.go @@ -0,0 +1,36 @@ +package main + +import ( + "os" + "syscall" + + "github.com/kandev/kandev/internal/agentctl/server/config" +) + +// applySIGPIPEDisposition decides whether this process keeps running after +// the backend that spawned it goes away. +// +// The launcher always pipes this process's stdout/stderr back to the +// spawning backend for logging (internal/agent/runtime/agentctl/launcher's +// pipeOutput). When that backend exits, the kernel closes its end of those +// pipes, and Go's default SIGPIPE disposition kills this process the next +// time it writes to fd 1 or 2, which every subsequent log line does. An +// inherited pipe is therefore one of the ways a departing backend takes its +// agentctl with it. +// +// Which behavior is correct depends on the capability. With agent survival +// enabled the process is meant to outlive a graceful restart, so the write +// must degrade to an ordinary error, which the logger already tolerates. +// With the capability disabled nothing may outlive the backend, so this kill +// path stays intact alongside the others. +// +// Called after configuration is resolved: a process that dies before then +// dies on the default disposition, and the short-lived CLI and +// credential-helper invocations dispatched before configuration keep the +// ordinary disposition a command-line tool should have. +func applySIGPIPEDisposition(cfg *config.Config, ignore func(...os.Signal)) { + if !cfg.AgentSurvivalEnabled { + return + } + ignore(syscall.SIGPIPE) +} diff --git a/apps/backend/cmd/agentctl/sigpipe_test.go b/apps/backend/cmd/agentctl/sigpipe_test.go new file mode 100644 index 0000000000..20e35f4baf --- /dev/null +++ b/apps/backend/cmd/agentctl/sigpipe_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "os" + "syscall" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/config" +) + +// TestSIGPIPEDispositionFollowsTheSurvivalCapability pins that ignoring +// SIGPIPE is part of the survival capability rather than an unconditional +// property of the binary. With the capability off, an inherited stdout whose +// reader has gone away must still be able to end this process, because that +// is one of the paths by which a departing backend takes its agentctl with +// it; removing it for every launch would leave a detached agentctl behind on +// installations that never enabled survival at all. +func TestSIGPIPEDispositionFollowsTheSurvivalCapability(t *testing.T) { + t.Run("disabled leaves the default disposition intact", func(t *testing.T) { + var ignored []os.Signal + applySIGPIPEDisposition(&config.Config{AgentSurvivalEnabled: false}, func(s ...os.Signal) { + ignored = append(ignored, s...) + }) + if len(ignored) != 0 { + t.Fatalf("signals ignored = %v, want none with the capability disabled", ignored) + } + }) + + t.Run("enabled ignores SIGPIPE", func(t *testing.T) { + var ignored []os.Signal + applySIGPIPEDisposition(&config.Config{AgentSurvivalEnabled: true}, func(s ...os.Signal) { + ignored = append(ignored, s...) + }) + if len(ignored) != 1 || ignored[0] != syscall.SIGPIPE { + t.Fatalf("signals ignored = %v, want exactly [SIGPIPE]", ignored) + } + }) +} diff --git a/apps/backend/cmd/agentctl/unowned_reaper_gate.go b/apps/backend/cmd/agentctl/unowned_reaper_gate.go new file mode 100644 index 0000000000..9dde682b2a --- /dev/null +++ b/apps/backend/cmd/agentctl/unowned_reaper_gate.go @@ -0,0 +1,24 @@ +package main + +import "github.com/kandev/kandev/internal/agentctl/server/config" + +// unownedReaper is satisfied by *api.ControlServer, narrowed here so the +// gate decision below is testable without a real control server. +type unownedReaper interface { + StartUnownedReaper() + StopUnownedReaper() +} + +// startUnownedReaperIfEnabled starts the unowned-period reaper only when the +// agent-survival capability is engaged for this launch, per +// StartUnownedReaper's own caller contract: starting it unconditionally +// would self-terminate a healthy attached instance once the period elapses, +// since nothing renews ownership without that capability's backend-side +// claim loop. StopUnownedReaper is always safe to call even when Start was +// never called, so the returned stop func needs no second branch. +func startUnownedReaperIfEnabled(cfg *config.Config, reaper unownedReaper) (stop func()) { + if cfg.AgentSurvivalEnabled { + reaper.StartUnownedReaper() + } + return reaper.StopUnownedReaper +} diff --git a/apps/backend/cmd/agentctl/unowned_reaper_gate_test.go b/apps/backend/cmd/agentctl/unowned_reaper_gate_test.go new file mode 100644 index 0000000000..c19189ac1d --- /dev/null +++ b/apps/backend/cmd/agentctl/unowned_reaper_gate_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/config" +) + +// fakeUnownedReaper is a test double for unownedReaper, letting the gate +// decision be asserted without exercising the real reaper goroutine's +// lifecycle (already covered directly by the api package's own tests). +type fakeUnownedReaper struct { + started bool + stopped bool +} + +func (f *fakeUnownedReaper) StartUnownedReaper() { f.started = true } +func (f *fakeUnownedReaper) StopUnownedReaper() { f.stopped = true } + +// TestStartUnownedReaperIfEnabled pins Layer 5.9's gate on +// StartUnownedReaper's own caller contract: unconditionally starting it on +// every agentctl launch would self-terminate a healthy attached instance +// once the period elapses, since nothing renews ownership without the +// agent-survival capability and its backend-side claim loop. +func TestStartUnownedReaperIfEnabled(t *testing.T) { + t.Run("enabled starts the reaper", func(t *testing.T) { + reaper := &fakeUnownedReaper{} + stop := startUnownedReaperIfEnabled(&config.Config{AgentSurvivalEnabled: true}, reaper) + if !reaper.started { + t.Fatal("StartUnownedReaper was not called with the capability enabled") + } + stop() + if !reaper.stopped { + t.Fatal("the returned stop function did not call StopUnownedReaper") + } + }) + + t.Run("disabled never starts the reaper but stop stays safe", func(t *testing.T) { + reaper := &fakeUnownedReaper{} + stop := startUnownedReaperIfEnabled(&config.Config{AgentSurvivalEnabled: false}, reaper) + if reaper.started { + t.Fatal("StartUnownedReaper was called with the capability disabled") + } + stop() + if !reaper.stopped { + t.Fatal("the returned stop function did not call StopUnownedReaper even when disabled") + } + }) +} diff --git a/apps/backend/internal/agent/runtime/agentctl/control.go b/apps/backend/internal/agent/runtime/agentctl/control.go index 1a9d8e18db..b3c8f6960b 100644 --- a/apps/backend/internal/agent/runtime/agentctl/control.go +++ b/apps/backend/internal/agent/runtime/agentctl/control.go @@ -6,11 +6,13 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" "sync" "time" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/subproc" mcpprofile "github.com/kandev/kandev/internal/mcp/profile" @@ -107,6 +109,18 @@ type InstanceInfo struct { AgentCommand string `json:"agent_command"` Env map[string]string `json:"env,omitempty"` CreatedAt time.Time `json:"created_at"` + // SessionID is the task session ID this instance was created for, if any. + SessionID string `json:"session_id,omitempty"` + // TaskID is the task ID this instance was created for, if any. + TaskID string `json:"task_id,omitempty"` + // WorkspaceSourceRoots is the instance's live, current source-root + // allowlist, read back rather than pushed (AC-EXECUTORS-SURVIVAL-002.14's + // "workspace source roots" reconstruction row). + WorkspaceSourceRoots []string `json:"workspace_source_roots,omitempty"` + // ProviderSessionID is the live agent CLI's own session identity, read + // back from the adopted instance (AC-EXECUTORS-SURVIVAL-002.14's + // "provider session identity" reconstruction row). + ProviderSessionID string `json:"provider_session_id,omitempty"` } // ControlClientOption configures optional ControlClient settings. @@ -350,6 +364,245 @@ func (c *ControlClient) GetInstance(ctx context.Context, instanceID string) (*In return &info, nil } +// IdentityInfo is the response from GET /identity: installation identity and +// the capability set this control server advertises. +type IdentityInfo struct { + ServerIdentity string `json:"server_identity"` + Capabilities []string `json:"capabilities"` + // UnownedPeriodMS is the reporting server's own resolved unowned period + // in milliseconds (AC-EXECUTORS-CONTROL-OWNERSHIP-003.2/.7) -- the value + // that server itself enforces, which an adopting backend's local config + // can disagree with across a restart. Use this, not local config, to + // compute an adopted server's ownership-renewal cadence. + UnownedPeriodMS int64 `json:"unowned_period_ms"` +} + +// ServerDetails carries the control-server values an adopting backend +// records but that are withheld from the unauthenticated identity endpoint. +type ServerDetails struct { + HomeDir string `json:"home_dir"` + DiagnosticLogPath string `json:"diagnostic_log_path"` +} + +// GetIdentity fetches the control server's identity and capability set. It +// deliberately does not require a valid auth token to succeed server-side +// (see agentctl's identity handler): identity retrieval decides adoption +// compatibility, so it cannot itself be gated behind the answer. +func (c *ControlClient) GetIdentity(ctx context.Context) (*IdentityInfo, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/identity", nil) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to get identity: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get identity: status %d", resp.StatusCode) + } + + var info IdentityInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &info, nil +} + +// ErrOwnershipCredentialSuperseded indicates the control server rejected an +// ownership-claim request because the presented credential is no longer +// current (HTTP 401) -- distinct from a transient failure (network error, +// 5xx). This is not the same backend's credential being stale from a +// renewal it initiated: a rotation this backend performs always leaves it +// holding the replacement, so a rejection here means some other party +// rotated the credential out from under it, most likely a second backend +// that adopted the server. Callers must not retry on this error +// (AC-EXECUTORS-CONTROL-OWNERSHIP-002.3). +var ErrOwnershipCredentialSuperseded = errors.New("ownership claim rejected: credential superseded") + +// ClaimOwnership establishes or renews this backend's ownership of the +// control server. It carries no instance identity: a server with zero +// instances is still owned. Refused (non-nil error) once the server's +// unowned-shutdown one-way door has fired. +func (c *ControlClient) ClaimOwnership(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/ownership/claim", nil) + if err != nil { + return err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to claim ownership: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusUnauthorized { + return ErrOwnershipCredentialSuperseded + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to claim ownership: status %d", resp.StatusCode) + } + return nil +} + +// CredentialRotationResult is the response from the rotate half of the +// two-phase credential rotation: a rotation identifier from the control +// server's strictly increasing sequence, and the replacement credential to +// authenticate with from now on. +type CredentialRotationResult struct { + RotationID int64 `json:"rotation_id"` + Credential string `json:"credential"` +} + +// ProveOwnership asks the control server to demonstrate it already holds +// the credential this backend has stored for it, over a challenge generated +// for this attempt. It carries no auth token, because it is what +// establishes that the server is worth sending one to: the answer is a +// keyed digest the server can only produce from the credential, and it +// never carries the credential itself. +func (c *ControlClient) ProveOwnership(ctx context.Context, challenge string) ([]string, error) { + payload, err := json.Marshal(map[string]string{"challenge": challenge}) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/ownership/prove", bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to request ownership proof: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to request ownership proof: status %d", resp.StatusCode) + } + + var decoded struct { + Proofs []string `json:"proofs"` + } + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return decoded.Proofs, nil +} + +// GetServerDetails reads the control-server values withheld from the +// unauthenticated identity endpoint. Requires a valid credential. +func (c *ControlClient) GetServerDetails(ctx context.Context) (*ServerDetails, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/ownership/details", nil) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to get server details: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get server details: status %d", resp.StatusCode) + } + + var details ServerDetails + if err := json.NewDecoder(resp.Body).Decode(&details); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &details, nil +} + +// RotateCredential presents this client's current auth token (via the +// normal Authorization header) and asks the control server to replace it. +// The response's replacement credential authenticates every operation +// immediately; the presented (now superseded) credential remains valid for +// a further rotation attempt or the ownership-shutdown operation only, +// until ConfirmCredentialRotation is called. Idempotent under retry: a +// retry presenting the same (superseded) credential while unconfirmed +// returns the same rotation again rather than allocating a new one. +func (c *ControlClient) RotateCredential(ctx context.Context) (*CredentialRotationResult, error) { + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/ownership/rotate", nil) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to rotate credential: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to rotate credential: status %d", resp.StatusCode) + } + + var result CredentialRotationResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &result, nil +} + +// ConfirmCredentialRotation names rotationID as durably stored (both the +// replacement credential and the control-server record's reference to it). +// Call this only after both writes have durably completed; sending it +// earlier and then failing can strand the server unadoptable until its +// unowned period elapses (AC-EXECUTORS-CONTROL-OWNERSHIP-002.7). +func (c *ControlClient) ConfirmCredentialRotation(ctx context.Context, rotationID int64) error { + body, err := json.Marshal(struct { + RotationID int64 `json:"rotation_id"` + }{RotationID: rotationID}) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/ownership/confirm", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to confirm credential rotation: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to confirm credential rotation: status %d", resp.StatusCode) + } + return nil +} + +// ShutdownControlServer invokes the ownership-shutdown operation: the +// control server stops every instance it supervises, together with their +// agent subprocesses, and exits. It requires no prior adoption or rotation +// and is authenticated by any credential in the acceptable set, including a +// superseded one that has not yet been confirmed away. +func (c *ControlClient) ShutdownControlServer(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/ownership/shutdown", nil) + if err != nil { + return err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to shut down control server: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to shut down control server: status %d", resp.StatusCode) + } + return nil +} + // ListInstances lists all running agent instances. func (c *ControlClient) ListInstances(ctx context.Context) ([]*InstanceInfo, error) { req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/instances", nil) @@ -375,3 +628,79 @@ func (c *ControlClient) ListInstances(ctx context.Context) ([]*InstanceInfo, err } return result.Instances, nil } + +// TurnOutcome is a retained terminal turn outcome for one instance +// (AC-EXECUTORS-SURVIVAL-004.1): the turn identifier the control server +// assigned, and the terminal event itself. +type TurnOutcome struct { + TurnID int64 `json:"turn_id"` + Event streams.AgentEvent `json:"event"` +} + +// GetTurnOutcome retrieves the named instance's retained last terminal turn +// outcome, if any. Returns (nil, nil) when the instance exists but nothing +// is retained -- the AC-EXECUTORS-SURVIVAL-004.5 case that publishes the +// session as running. The read is repeatable: it never discards what it +// returns (AC-EXECUTORS-SURVIVAL-004.6); only AckTurnOutcome does. +func (c *ControlClient) GetTurnOutcome(ctx context.Context, instanceID string) (*TurnOutcome, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/instances/"+instanceID+"/turn-outcome", nil) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to get turn outcome: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("instance %q not found", instanceID) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get turn outcome: status %d", resp.StatusCode) + } + + var body struct { + Retained bool `json:"retained"` + TurnID int64 `json:"turn_id"` + Event streams.AgentEvent `json:"event"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + if !body.Retained { + return nil, nil + } + return &TurnOutcome{TurnID: body.TurnID, Event: body.Event}, nil +} + +// AckTurnOutcome acknowledges the named instance's retained outcome by turn +// identifier, discarding it. Naming an identifier the control server no +// longer holds, or never held, is accepted and changes nothing +// (AC-EXECUTORS-SURVIVAL-004.6), so a retried acknowledgement is always safe. +func (c *ControlClient) AckTurnOutcome(ctx context.Context, instanceID string, turnID int64) error { + body, err := json.Marshal(struct { + TurnID int64 `json:"turn_id"` + }{TurnID: turnID}) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/instances/"+instanceID+"/turn-outcome/ack", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to ack turn outcome: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to ack turn outcome: status %d", resp.StatusCode) + } + return nil +} diff --git a/apps/backend/internal/agent/runtime/agentctl/control_test.go b/apps/backend/internal/agent/runtime/agentctl/control_test.go index 416cff6a04..77c58440b7 100644 --- a/apps/backend/internal/agent/runtime/agentctl/control_test.go +++ b/apps/backend/internal/agent/runtime/agentctl/control_test.go @@ -607,34 +607,11 @@ func TestGetInstance_FailureModes(t *testing.T) { } } -func TestListInstances_UnwrapsInstancesEnvelope(t *testing.T) { - srv, got := captureServer(t, jsonResponder(http.StatusOK, `{ - "instances":[ - {"id":"a","port":1,"status":"running","workspace_path":"/w/a","agent_command":"cmd-a"}, - {"id":"b","port":2,"status":"stopped","workspace_path":"/w/b","agent_command":"cmd-b"} - ] - }`)) - - instances, err := newTestControlClient(t, srv).ListInstances(context.Background()) - if err != nil { - t.Fatalf("ListInstances: %v", err) - } - - if got.Method != http.MethodGet || got.Path != "/api/v1/instances" { - t.Errorf("request = %s %s, want GET /api/v1/instances", got.Method, got.Path) - } - if len(instances) != 2 { - t.Fatalf("instances = %d, want 2", len(instances)) - } - if instances[0].ID != "a" || instances[0].Port != 1 || - instances[0].Status != "running" || instances[0].WorkspacePath != "/w/a" || - instances[0].AgentCommand != "cmd-a" { - t.Errorf("instances[0] = %+v", instances[0]) - } - if instances[1].ID != "b" || instances[1].Port != 2 || instances[1].Status != "stopped" { - t.Errorf("instances[1] = %+v", instances[1]) - } -} +// The envelope-decode + request method/path contract this used to pin with a +// hand-written response fixture is now covered end to end (real handler, real +// client) by TestListInstancesReturnsEnvelopeWithSessionAndTaskID in +// internal/agentctl/server/api — a fixture here could assert an envelope +// shape the real handler had stopped producing without ever failing. func TestListInstances_FailureModes(t *testing.T) { tests := []struct { @@ -661,3 +638,52 @@ func TestListInstances_FailureModes(t *testing.T) { }) } } + +// TestClaimOwnership_UnauthorizedReturnsSupersededSentinel pins +// AC-EXECUTORS-CONTROL-OWNERSHIP-002.3: a 401 response must be distinguished +// from any other failure via ErrOwnershipCredentialSuperseded, so a caller +// like OwnershipRenewer can tell "credential is no longer current" apart +// from a transient failure it should retry. +func TestClaimOwnership_UnauthorizedReturnsSupersededSentinel(t *testing.T) { + srv := httptest.NewServer(jsonResponder(http.StatusUnauthorized, `{"error":"invalid auth token"}`)) + t.Cleanup(srv.Close) + + err := newTestControlClient(t, srv).ClaimOwnership(context.Background()) + if !errors.Is(err, ErrOwnershipCredentialSuperseded) { + t.Fatalf("error = %v, want errors.Is(err, ErrOwnershipCredentialSuperseded)", err) + } +} + +func TestClaimOwnership_OtherFailureModesDoNotReturnSupersededSentinel(t *testing.T) { + tests := []struct { + name string + status int + wantErr string + }{ + {"internal server error", http.StatusInternalServerError, "failed to claim ownership: status 500"}, + {"not found", http.StatusNotFound, "failed to claim ownership: status 404"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(jsonResponder(tc.status, `{}`)) + t.Cleanup(srv.Close) + + err := newTestControlClient(t, srv).ClaimOwnership(context.Background()) + if errors.Is(err, ErrOwnershipCredentialSuperseded) { + t.Fatalf("error = %v, want NOT errors.Is(err, ErrOwnershipCredentialSuperseded)", err) + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error = %v, want %q", err, tc.wantErr) + } + }) + } +} + +func TestClaimOwnership_SuccessReturnsNilError(t *testing.T) { + srv := httptest.NewServer(jsonResponder(http.StatusOK, `{}`)) + t.Cleanup(srv.Close) + + if err := newTestControlClient(t, srv).ClaimOwnership(context.Background()); err != nil { + t.Fatalf("ClaimOwnership() error = %v, want nil", err) + } +} diff --git a/apps/backend/internal/agent/runtime/agentctl/launcher/launcher.go b/apps/backend/internal/agent/runtime/agentctl/launcher/launcher.go index 06400a12b9..a1eb072093 100644 --- a/apps/backend/internal/agent/runtime/agentctl/launcher/launcher.go +++ b/apps/backend/internal/agent/runtime/agentctl/launcher/launcher.go @@ -22,6 +22,7 @@ import ( agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" commonconfig "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/common/netprobe" "go.uber.org/zap" ) @@ -141,6 +142,16 @@ func agentctlBinaryName() string { return "agentctl" } +// FindAgentctlBinary resolves the local agentctl binary path using the same +// search order New uses for an unspecified BinaryPath. Exported so a caller +// that adopts an already-running control server -- and therefore never +// calls New -- can still resolve the local binary artifact for callers that +// need it (e.g. uploading it into a remote/container executor), independent +// of whether this launch adopted or spawned. +func FindAgentctlBinary() string { + return findAgentctlBinary() +} + // findAgentctlBinary attempts to locate the agentctl binary. func findAgentctlBinary() string { name := agentctlBinaryName() @@ -245,11 +256,22 @@ func (l *Launcher) buildAndStartProcess(nonce string) error { overrides = append(overrides, commonconfig.InternalAgentctlStartupConfigEnv+"="+encoded) } l.cmd.Env = environmentWithOverrides(os.Environ(), overrides...) - l.cmd.SysProcAttr = buildSysProcAttr() + l.cmd.SysProcAttr = buildSysProcAttr(l.startupConfig.AgentSurvivalEnabled) - pipeWrite, err := setupLivenessPipe(l.cmd) - if err != nil { - return err + // Kill-path #1 (design 01's kill-paths list, "parent-liveness pipe"): + // skipped entirely when the capability is engaged for this launch, for + // the same AC-EXECUTORS-SURVIVAL-001.2 reason buildSysProcAttr above + // omits Pdeathsig -- both fire on any parent death, including SIGKILL, + // independent of anything Stop() does. + var pipeWrite *os.File + if l.startupConfig.AgentSurvivalEnabled { + clearInheritedLivenessPipeEnv(l.cmd) + } else { + var err error + pipeWrite, err = setupLivenessPipe(l.cmd) + if err != nil { + return err + } } stdout, err := l.cmd.StdoutPipe() @@ -322,8 +344,20 @@ func (l *Launcher) performHandshake(ctx context.Context, nonce string) (string, return token, nil } -// Stop gracefully shuts down the agentctl subprocess. +// Stop gracefully shuts down the agentctl subprocess. Kill-path #5 (design +// 01's kill-paths list, "registered cleanup"): when the agent-survival +// capability is engaged for this launch, Stop is the sole thing the +// backend's registered shutdown cleanup calls (see provider.go), and +// AC-EXECUTORS-SURVIVAL-001.1 requires that a graceful backend shutdown +// "shall not issue a stop" to the control server or its instances -- so +// Stop becomes a no-op rather than closing the (already-unarmed) pipe and +// sending SIGTERM. func (l *Launcher) Stop(ctx context.Context) error { + if l.startupConfig.AgentSurvivalEnabled { + l.logger.Info("agent-survival capability engaged; leaving agentctl running instead of stopping it") + return nil + } + l.mu.Lock() if l.cmd == nil || l.cmd.Process == nil { @@ -393,14 +427,20 @@ func (l *Launcher) closeParentPipeLocked() { } } -// checkPortAvailable verifies the given port is not in use. -// It checks by attempting a wildcard bind (matching what agentctl does with ":port"). +// checkPortAvailable reports whether the given port is free, using the same +// probe contract the process launcher uses (internal/common/netprobe): a +// dual-stack loopback connect must find nothing listening AND a fresh +// loopback bind must succeed. +// +// A bind alone is not enough. A surviving agentctl holds the wildcard +// address, and on macOS/BSD a bind against an active wildcard listener can +// still succeed, which would report the occupied control port as free and +// send this launch to a second server on a port the record does not name. func checkPortAvailable(port int) error { - ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) - if err != nil { - return err + if !netprobe.PortAvailable(port) { + return fmt.Errorf("port %d is already in use", port) } - return ln.Close() + return nil } // findFreePort asks the OS for an available port by binding to :0. diff --git a/apps/backend/internal/agent/runtime/agentctl/launcher/launcher_pipe_unix.go b/apps/backend/internal/agent/runtime/agentctl/launcher/launcher_pipe_unix.go index a536a2942f..ea5bdf8d31 100644 --- a/apps/backend/internal/agent/runtime/agentctl/launcher/launcher_pipe_unix.go +++ b/apps/backend/internal/agent/runtime/agentctl/launcher/launcher_pipe_unix.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "strings" ) // setupLivenessPipe creates a pipe and configures the command to pass the @@ -42,3 +43,22 @@ func closeChildPipeEnd(cmd *exec.Cmd) { _ = cmd.ExtraFiles[0].Close() } } + +// clearInheritedLivenessPipeEnv removes any KANDEV_PARENT_PIPE_FD the +// launcher process itself inherited from its own ambient environment (for +// example when it is itself running under a survivable agentctl launch). +// Used when the pipe is deliberately not armed for this launch (kill-path +// #1 disabled, AC-EXECUTORS-SURVIVAL-001.2): without this, a stale inherited +// value would point the child at an FD 3 that ExtraFiles never set up, +// which monitorParentLiveness would misinterpret rather than correctly +// treating as absent. +func clearInheritedLivenessPipeEnv(cmd *exec.Cmd) { + filtered := cmd.Env[:0] + for _, entry := range cmd.Env { + if strings.HasPrefix(entry, "KANDEV_PARENT_PIPE_FD=") { + continue + } + filtered = append(filtered, entry) + } + cmd.Env = filtered +} diff --git a/apps/backend/internal/agent/runtime/agentctl/launcher/launcher_pipe_windows.go b/apps/backend/internal/agent/runtime/agentctl/launcher/launcher_pipe_windows.go index 5da371c503..87a84504e4 100644 --- a/apps/backend/internal/agent/runtime/agentctl/launcher/launcher_pipe_windows.go +++ b/apps/backend/internal/agent/runtime/agentctl/launcher/launcher_pipe_windows.go @@ -20,3 +20,7 @@ func closePipeOnStartFailure(_ *os.File, _ *exec.Cmd) {} // closeChildPipeEnd is a no-op on Windows (no pipe to close). func closeChildPipeEnd(_ *exec.Cmd) {} + +// clearInheritedLivenessPipeEnv is a no-op on Windows: setupLivenessPipe +// never sets KANDEV_PARENT_PIPE_FD here, so there is nothing to strip. +func clearInheritedLivenessPipeEnv(_ *exec.Cmd) {} diff --git a/apps/backend/internal/agent/runtime/agentctl/launcher/platform_unix_test.go b/apps/backend/internal/agent/runtime/agentctl/launcher/platform_unix_test.go index c78b0ee7c0..7cd81ea6e2 100644 --- a/apps/backend/internal/agent/runtime/agentctl/launcher/platform_unix_test.go +++ b/apps/backend/internal/agent/runtime/agentctl/launcher/platform_unix_test.go @@ -49,7 +49,7 @@ func TestLauncherForceKillSignalsProcessGroup(t *testing.T) { childPIDFile := filepath.Join(tmp, "child.pid") cmd := exec.Command("sh", "-c", `sleep 30 & echo $! > "$CHILD_PID_FILE"; wait`) cmd.Env = append(os.Environ(), "CHILD_PID_FILE="+childPIDFile) - cmd.SysProcAttr = buildSysProcAttr() + cmd.SysProcAttr = buildSysProcAttr(false) if err := cmd.Start(); err != nil { t.Fatalf("start command: %v", err) } diff --git a/apps/backend/internal/agent/runtime/agentctl/launcher/survival_kill_path_test.go b/apps/backend/internal/agent/runtime/agentctl/launcher/survival_kill_path_test.go new file mode 100644 index 0000000000..3ede4e5f31 --- /dev/null +++ b/apps/backend/internal/agent/runtime/agentctl/launcher/survival_kill_path_test.go @@ -0,0 +1,180 @@ +//go:build !windows + +package launcher + +import ( + "context" + "os/exec" + "strings" + "syscall" + "testing" + "time" + + commonconfig "github.com/kandev/kandev/internal/common/config" +) + +// TestBuildAndStartProcessSkipsParentLivenessPipeWhenSurvivalEnabled pins +// kill-path #1 from design 01's kill-paths list ("parent-liveness pipe"): +// when the agent-survival capability is engaged for this launch, agentctl +// must not depend on any backend shutdown step to keep running +// (AC-EXECUTORS-SURVIVAL-001.2), so the launcher must not arm the pipe that +// would otherwise make agentctl self-terminate the instant the write-end +// closes -- including on a backend SIGKILL, which never gives Stop() a +// chance to run. +func TestBuildAndStartProcessSkipsParentLivenessPipeWhenSurvivalEnabled(t *testing.T) { + l := newSurvivalKillPathTestLauncher(t, true) + if err := l.buildAndStartProcess("test-nonce"); err != nil { + t.Fatalf("buildAndStartProcess: %v", err) + } + t.Cleanup(func() { _ = l.cmd.Process.Kill() }) + <-l.exited // "true" exits immediately; wait so ExtraFiles/env are stable to inspect + + if len(l.cmd.ExtraFiles) != 0 { + t.Fatalf("ExtraFiles = %v, want none: the parent-liveness pipe must not be armed", l.cmd.ExtraFiles) + } + if l.parentPipe != nil { + t.Fatal("parentPipe was set even though the capability is engaged for this launch") + } + for _, env := range l.cmd.Env { + if strings.HasPrefix(env, "KANDEV_PARENT_PIPE_FD=") { + t.Fatalf("KANDEV_PARENT_PIPE_FD was set in the child environment: %s", env) + } + } +} + +// TestBuildAndStartProcessStripsInheritedLivenessPipeEnvWhenSurvivalEnabled +// covers a real hazard this session's own sandbox surfaced: the launcher +// process can itself be running under an ambient KANDEV_PARENT_PIPE_FD (for +// example a survivable agentctl launch nested inside another one). Without +// stripping it, that stale value would leak into the child's environment +// even though ExtraFiles was never set up to back FD 3, and +// monitorParentLiveness would misread it as a live pipe rather than absent. +func TestBuildAndStartProcessStripsInheritedLivenessPipeEnvWhenSurvivalEnabled(t *testing.T) { + t.Setenv("KANDEV_PARENT_PIPE_FD", "3") + + l := newSurvivalKillPathTestLauncher(t, true) + if err := l.buildAndStartProcess("test-nonce"); err != nil { + t.Fatalf("buildAndStartProcess: %v", err) + } + t.Cleanup(func() { _ = l.cmd.Process.Kill() }) + <-l.exited + + for _, env := range l.cmd.Env { + if strings.HasPrefix(env, "KANDEV_PARENT_PIPE_FD=") { + t.Fatalf("inherited KANDEV_PARENT_PIPE_FD leaked into the child environment: %s", env) + } + } +} + +// TestBuildAndStartProcessArmsParentLivenessPipeWhenSurvivalDisabled is the +// regression companion: today's behavior (capability off, or an unmanaged +// launch) must be unchanged -- the pipe is armed exactly as before. +func TestBuildAndStartProcessArmsParentLivenessPipeWhenSurvivalDisabled(t *testing.T) { + l := newSurvivalKillPathTestLauncher(t, false) + if err := l.buildAndStartProcess("test-nonce"); err != nil { + t.Fatalf("buildAndStartProcess: %v", err) + } + t.Cleanup(func() { _ = l.cmd.Process.Kill() }) + <-l.exited + + if len(l.cmd.ExtraFiles) != 1 { + t.Fatalf("ExtraFiles = %v, want exactly 1 (the pipe read-end)", l.cmd.ExtraFiles) + } + if l.parentPipe == nil { + t.Fatal("parentPipe was not set with the capability disabled") + } + _ = l.parentPipe.Close() +} + +// TestLauncherStopIsANoOpWhenSurvivalEnabled pins kill-path #5 ("registered +// cleanup"): AC-EXECUTORS-SURVIVAL-001.1 requires that a graceful backend +// shutdown "shall not issue a stop" to the control server or its instances +// when the capability is enabled. Stop() is the only thing the backend's +// registered cleanup calls (see provider.go), so making Stop() itself a +// no-op closes that path without needing a second "should I actually stop" +// branch at the one call site. +func TestLauncherStopIsANoOpWhenSurvivalEnabled(t *testing.T) { + cmd := exec.Command("sleep", "30") + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + t.Fatalf("start long-lived test process: %v", err) + } + t.Cleanup(func() { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }) + + l := &Launcher{ + cmd: cmd, + exited: make(chan struct{}), // never closed: nothing reaps this process in the test + logger: newLauncherTestLogger(t), + startupConfig: commonconfig.AgentctlStartupConfig{Configured: true, AgentSurvivalEnabled: true}, + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := l.Stop(ctx); err != nil { + t.Fatalf("Stop() returned an error for a no-op stop: %v", err) + } + + if err := cmd.Process.Signal(syscall.Signal(0)); err != nil { + t.Fatalf("process is no longer alive after a supposedly no-op Stop(): %v", err) + } +} + +// TestLauncherStopTerminatesTheServerWhenSurvivalDisabled is the closed side +// of the same kill path. With the capability off, the control server is the +// backend's to own for the length of the backend's life, so the registered +// cleanup's Stop() must actually terminate it rather than leave an orphan +// running with no backend attached to it. +func TestLauncherStopTerminatesTheServerWhenSurvivalDisabled(t *testing.T) { + cmd := exec.Command("sleep", "30") + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + t.Fatalf("start long-lived test process: %v", err) + } + t.Cleanup(func() { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }) + + exited := make(chan struct{}) + go func() { + _ = cmd.Wait() + close(exited) + }() + + l := &Launcher{ + cmd: cmd, + exited: exited, + logger: newLauncherTestLogger(t), + startupConfig: commonconfig.AgentctlStartupConfig{Configured: true, AgentSurvivalEnabled: false}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := l.Stop(ctx); err != nil { + t.Fatalf("Stop() = %v, want nil", err) + } + + select { + case <-exited: + case <-time.After(5 * time.Second): + t.Fatal("process is still running after Stop() with the capability disabled") + } +} + +func newSurvivalKillPathTestLauncher(t *testing.T, survivalEnabled bool) *Launcher { + t.Helper() + binary, err := exec.LookPath("true") + if err != nil { + t.Skipf("no 'true' binary on PATH: %v", err) + } + return &Launcher{ + binaryPath: binary, + host: "localhost", + port: 0, + exited: make(chan struct{}), + logger: newLauncherTestLogger(t), + startupConfig: commonconfig.AgentctlStartupConfig{ + Configured: true, + IdleReaperInterval: time.Minute, + NotificationQueueCapacity: 4096, + AgentSurvivalEnabled: survivalEnabled, + }, + } +} diff --git a/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_default.go b/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_default.go index 16c9316fa6..9e5317072a 100644 --- a/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_default.go +++ b/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_default.go @@ -4,7 +4,10 @@ package launcher import "syscall" -func buildSysProcAttr() *syscall.SysProcAttr { +// buildSysProcAttr configures the child's process attributes. survivalEnabled +// is unused on this platform: syscall.SysProcAttr here carries no Pdeathsig +// field (that kill path is Linux-only), so there is nothing to gate. +func buildSysProcAttr(_ bool) *syscall.SysProcAttr { return &syscall.SysProcAttr{ // Keep standalone agentctl out of the terminal foreground process // group so Ctrl+C is sequenced by the backend shutdown path. diff --git a/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_linux.go b/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_linux.go index b634567127..e8f87d7ef3 100644 --- a/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_linux.go +++ b/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_linux.go @@ -4,11 +4,19 @@ package launcher import "syscall" -func buildSysProcAttr() *syscall.SysProcAttr { - return &syscall.SysProcAttr{ - // Keep standalone agentctl out of the terminal foreground process - // group so Ctrl+C is sequenced by the backend shutdown path. - Setpgid: true, - Pdeathsig: syscall.SIGTERM, +// buildSysProcAttr configures the child's process attributes. Pdeathsig is +// kill-path #2 of design 01's kill-paths list: it fires from the kernel on +// ANY parent exit, including SIGKILL, so it must be omitted entirely when +// the agent-survival capability is engaged for this launch +// (AC-EXECUTORS-SURVIVAL-001.2 forbids depending on any backend shutdown +// step). Setpgid stays set regardless -- it isolates agentctl from terminal +// Ctrl+C, unrelated to survival. +func buildSysProcAttr(survivalEnabled bool) *syscall.SysProcAttr { + attr := &syscall.SysProcAttr{ + Setpgid: true, } + if !survivalEnabled { + attr.Pdeathsig = syscall.SIGTERM + } + return attr } diff --git a/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_linux_test.go b/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_linux_test.go new file mode 100644 index 0000000000..614aa2f3fc --- /dev/null +++ b/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_linux_test.go @@ -0,0 +1,33 @@ +//go:build linux + +package launcher + +import ( + "syscall" + "testing" +) + +// TestBuildSysProcAttrOmitsPdeathsigWhenSurvivalEnabled pins kill-path #2 +// from design 01's kill-paths list ("Pdeathsig"): AC-EXECUTORS-SURVIVAL-001.2 +// requires agentctl to keep running when the backend exits without a +// graceful shutdown, including SIGKILL, "without depending on any backend +// shutdown step" -- Pdeathsig fires from the kernel on ANY parent exit, so it +// must not be armed at all when the capability is engaged for this launch. +func TestBuildSysProcAttrOmitsPdeathsigWhenSurvivalEnabled(t *testing.T) { + attr := buildSysProcAttr(true) + if attr.Pdeathsig != 0 { + t.Fatalf("Pdeathsig = %v, want unset (0) when the capability is engaged", attr.Pdeathsig) + } + if !attr.Setpgid { + t.Error("Setpgid must stay true regardless of the capability: it isolates agentctl from terminal Ctrl+C, unrelated to survival") + } +} + +// TestBuildSysProcAttrSetsPdeathsigWhenSurvivalDisabled is the regression +// companion: today's behavior is unchanged when the capability is off. +func TestBuildSysProcAttrSetsPdeathsigWhenSurvivalDisabled(t *testing.T) { + attr := buildSysProcAttr(false) + if attr.Pdeathsig != syscall.SIGTERM { + t.Fatalf("Pdeathsig = %v, want SIGTERM when the capability is disabled", attr.Pdeathsig) + } +} diff --git a/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_other.go b/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_other.go index b0dee6b390..103eee33da 100644 --- a/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_other.go +++ b/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_other.go @@ -4,6 +4,8 @@ package launcher import "syscall" -func buildSysProcAttr() *syscall.SysProcAttr { +// buildSysProcAttr configures the child's process attributes. survivalEnabled +// is unused on this platform: there is no parent-death primitive here to gate. +func buildSysProcAttr(_ bool) *syscall.SysProcAttr { return &syscall.SysProcAttr{} } diff --git a/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_test.go b/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_test.go index 990d5851b3..05ce1a8867 100644 --- a/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_test.go +++ b/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_test.go @@ -7,7 +7,7 @@ import ( ) func TestBuildSysProcAttr_IsolatesAgentctlFromTerminalInterrupt(t *testing.T) { - attr := buildSysProcAttr() + attr := buildSysProcAttr(false) if !attr.Setpgid { t.Error("Setpgid must be true: standalone agentctl should not receive terminal Ctrl+C directly") } diff --git a/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_windows.go b/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_windows.go index 18f3c2ebf4..a9d51c51e4 100644 --- a/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_windows.go +++ b/apps/backend/internal/agent/runtime/agentctl/launcher/sysprocattr_windows.go @@ -4,7 +4,11 @@ package launcher import "syscall" -func buildSysProcAttr() *syscall.SysProcAttr { +// buildSysProcAttr configures the child's process attributes. survivalEnabled +// is unused on this platform: the capability is unavailable on Windows (see +// agentSurvivalAvailability in runtimeflags/registry.go), and the job-object +// kill path (design 01's kill-path #3) stays unconditional regardless. +func buildSysProcAttr(_ bool) *syscall.SysProcAttr { // CREATE_NEW_PROCESS_GROUP so Ctrl+C doesn't propagate directly return &syscall.SysProcAttr{ CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, diff --git a/apps/backend/internal/agent/runtime/control_server.go b/apps/backend/internal/agent/runtime/control_server.go new file mode 100644 index 0000000000..ff89b13d99 --- /dev/null +++ b/apps/backend/internal/agent/runtime/control_server.go @@ -0,0 +1,118 @@ +package runtime + +import ( + "context" + "time" + + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/secrets" +) + +// Standalone control-server ownership, exposed through the agent-runtime seam. +// +// The adoption handshake, its durable record, and the ownership renewer all +// live in runtime/lifecycle, but the caller that drives them at startup is the +// backend application package, which must not import lifecycle directly (see +// apps/backend/CLAUDE.md, "only internal/agent/runtime/ ... may import +// runtime/lifecycle"). These aliases and wrappers are that seam, in the same +// shape as SSHTaskDirReclaimer above: higher-level packages depend on this +// contract rather than on the lifecycle implementation. + +// InheritedRecordScope reports what this launch found at the recorded control +// endpoint, which decides how an inherited executors_running row may be judged. +type InheritedRecordScope = lifecycle.InheritedRecordScope + +const ( + // InheritedRecordScopeNoServer means nothing answered, so no server holds + // the inherited rows and the process-identifier probe applies. + InheritedRecordScopeNoServer = lifecycle.InheritedRecordScopeNoServer + // InheritedRecordScopeForeignServer means a server answered but was not + // adopted, so its instances may still be alive on a server this backend + // does not drive. + InheritedRecordScopeForeignServer = lifecycle.InheritedRecordScopeForeignServer + // InheritedRecordScopeAdopted means this backend adopted the recorded + // server, so its enumeration is authoritative for inherited rows. + InheritedRecordScopeAdopted = lifecycle.InheritedRecordScopeAdopted +) + +// AdoptionRecordStore is the narrow persistence contract the adoption +// orchestration reads and writes the installation-scoped record through. +type AdoptionRecordStore = lifecycle.AdoptionRecordStore + +// AdoptionControlClient is the ownership surface adoption drives on a live +// control server. +type AdoptionControlClient = lifecycle.AdoptionControlClient + +// AdoptionControlClientFactory builds a client targeting a recorded endpoint. +type AdoptionControlClientFactory = lifecycle.AdoptionControlClientFactory + +// AdoptionOutcome is the result of attempting to adopt a detached server. +type AdoptionOutcome = lifecycle.AdoptionOutcome + +// OwnershipRenewer keeps an adopted server's ownership lease renewed for as +// long as this backend drives it. +type OwnershipRenewer = lifecycle.OwnershipRenewer + +// OwnershipClaimer is the single ownership-claim operation the renewer issues. +type OwnershipClaimer = lifecycle.OwnershipClaimer + +// RequiredSurvivalCapabilities is the capability set this backend requires of +// a control server before adopting it. +var RequiredSurvivalCapabilities = lifecycle.RequiredSurvivalCapabilities + +// AttemptAdoptControlServer runs the adoption handshake against the recorded +// control endpoint and reports whether this backend may drive that server. +func AttemptAdoptControlServer( + ctx context.Context, + store AdoptionRecordStore, + secretStore secrets.SecretStore, + newClient AdoptionControlClientFactory, + homeDir string, + requiredCapabilities []string, + recoveryReadTimeout time.Duration, + recoveryReadRetries int, + log *logger.Logger, +) AdoptionOutcome { + return lifecycle.AttemptAdoptControlServer(ctx, store, secretStore, newClient, homeDir, + requiredCapabilities, recoveryReadTimeout, recoveryReadRetries, log) +} + +// ReclaimUnneededControlServer stops and clears a recorded server this launch +// will not adopt because the capability is disabled. +func ReclaimUnneededControlServer( + ctx context.Context, + store AdoptionRecordStore, + secretStore secrets.SecretStore, + newClient AdoptionControlClientFactory, + homeDir string, + recoveryReadTimeout time.Duration, + recoveryReadRetries int, + log *logger.Logger, +) { + lifecycle.ReclaimUnneededControlServer(ctx, store, secretStore, newClient, homeDir, + recoveryReadTimeout, recoveryReadRetries, log) +} + +// RecordFreshControlServer persists the record and credential for a control +// server this backend just spawned, so a later launch can adopt it. +func RecordFreshControlServer( + ctx context.Context, + store AdoptionRecordStore, + secretStore secrets.SecretStore, + client AdoptionControlClient, + endpoint string, + credential string, +) error { + return lifecycle.RecordFreshControlServer(ctx, store, secretStore, client, endpoint, credential) +} + +// NewOwnershipRenewer builds the renewer that holds an adopted server's +// ownership lease for this backend's lifetime. +func NewOwnershipRenewer( + claimer OwnershipClaimer, + interval time.Duration, + log *logger.Logger, +) *OwnershipRenewer { + return lifecycle.NewOwnershipRenewer(claimer, interval, log) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/agentctl_startup.go b/apps/backend/internal/agent/runtime/lifecycle/agentctl_startup.go index d493d34894..81feeddd47 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/agentctl_startup.go +++ b/apps/backend/internal/agent/runtime/lifecycle/agentctl_startup.go @@ -2,8 +2,23 @@ package lifecycle import ( commonconfig "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/task/models" ) +// agentctlStartupConfigForExecutor keeps the host-only survival reaper out of +// managed remote/container environments. The backend owns the renewal loop for +// its host control server; a remote agentctl has no such loop and would reap +// its own instances after the unowned timeout. +func agentctlStartupConfigForExecutor( + startup commonconfig.AgentctlStartupConfig, + executorType string, +) commonconfig.AgentctlStartupConfig { + if models.IsRemoteExecutorType(models.ExecutorType(executorType)) { + startup.AgentSurvivalEnabled = false + } + return startup +} + func validateAgentctlStartupConfig(startup commonconfig.AgentctlStartupConfig) error { if !startup.Configured { return nil diff --git a/apps/backend/internal/agent/runtime/lifecycle/agentctl_startup_test.go b/apps/backend/internal/agent/runtime/lifecycle/agentctl_startup_test.go index f5f5790a79..0afdae5f16 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/agentctl_startup_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/agentctl_startup_test.go @@ -7,6 +7,7 @@ import ( "github.com/kandev/kandev/internal/agent/agents" commonconfig "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/task/models" ) func testAgentctlStartupConfig() commonconfig.AgentctlStartupConfig { @@ -19,6 +20,33 @@ func testAgentctlStartupConfig() commonconfig.AgentctlStartupConfig { } } +func TestAgentctlStartupConfigDisablesSurvivalOutsideHostExecutors(t *testing.T) { + for _, executorType := range []models.ExecutorType{ + models.ExecutorTypeLocalDocker, + models.ExecutorTypeRemoteDocker, + models.ExecutorTypeSprites, + models.ExecutorTypeSSH, + models.ExecutorTypeKubernetes, + models.ExecutorTypeMockRemote, + } { + startup := testAgentctlStartupConfig() + startup.AgentSurvivalEnabled = true + got := agentctlStartupConfigForExecutor(startup, string(executorType)) + if got.AgentSurvivalEnabled { + t.Fatalf("executor %q retained host survival reaper", executorType) + } + } + + for _, executorType := range []string{string(models.ExecutorTypeLocal), string(models.ExecutorTypeWorktree), legacyExecutorTypeLocalPC} { + startup := testAgentctlStartupConfig() + startup.AgentSurvivalEnabled = true + got := agentctlStartupConfigForExecutor(startup, executorType) + if !got.AgentSurvivalEnabled { + t.Fatalf("host executor %q lost survival reaper", executorType) + } + } +} + func TestAgentctlStartupContractReachesContainerSpriteAndSSHEnvironments(t *testing.T) { startup := testAgentctlStartupConfig() encoded, err := commonconfig.EncodeAgentctlStartupConfig(startup) diff --git a/apps/backend/internal/agent/runtime/lifecycle/control_server_adoption.go b/apps/backend/internal/agent/runtime/lifecycle/control_server_adoption.go new file mode 100644 index 0000000000..c505996f65 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/control_server_adoption.go @@ -0,0 +1,561 @@ +package lifecycle + +import ( + "context" + "fmt" + "strings" + "time" + + "go.uber.org/zap" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/task/models" +) + +// RequiredSurvivalCapabilities is the capability set this backend requires +// of a control server before it will adopt it (design 01, "Capability +// compatibility": "The backend requires a set and adopts only when its +// required set is a subset"). Must stay a subset of +// api.SurvivalCapabilities -- kept as an independent literal here rather +// than imported, since agentctl/server/api is not a package the backend +// package layer depends on. +var RequiredSurvivalCapabilities = []string{"agent-survival.v1"} + +// AdoptionRecordStore is the narrow persistence interface the adoption +// orchestration reads and writes the single installation-scoped +// control-server record through, satisfied by *sqlite.Repository. +type AdoptionRecordStore interface { + GetControlServerRecord(ctx context.Context) (*models.ControlServerRecord, error) + UpsertControlServerRecord(ctx context.Context, record *models.ControlServerRecord) error +} + +// staleExecutionRepairStore is the optional persistence capability +// AC-EXECUTORS-CONTROL-OWNERSHIP-004.3 and AC-EXECUTORS-SURVIVAL-005.5's +// immediate-repair step need: list this installation's live standalone +// recovery-inventory records and repair each in place. Satisfied by +// *sqlite.Repository alongside AdoptionRecordStore; type-asserted rather +// than folded into AdoptionRecordStore itself so RecordFreshControlServer's +// store parameter and every existing AdoptionRecordStore test double stay +// unaffected, matching this package's turnOutcomeApplier/recoveryStopper +// optional-capability convention. +type staleExecutionRepairStore interface { + ListExecutorsRunningLiveStandalone(ctx context.Context) ([]*models.ExecutorRunning, error) + RepairExecutorRunningDead(ctx context.Context, sessionID string) error +} + +// repairLiveStandaloneRecordsAfterOwnServerStopped implements +// AC-EXECUTORS-CONTROL-OWNERSHIP-004.3's "repair their recovery-inventory +// records itself... performed immediately" for a control server this +// backend has just proved is its own and has itself successfully stopped: +// unlike the deferred unowned-shutdown repair of AC-EXECUTORS-CONTROL- +// OWNERSHIP-003.5 (no backend attached at kill time), a backend is attached +// here and can write the durable store right away. Since exactly one +// control server is ever recorded per installation, stopping it makes every +// currently-live standalone recovery-inventory record definitively dead -- +// there is no live enumeration to correlate against, unlike an ordinary +// recovery pass. Repair preserves resume token and worktree identity +// (RepairExecutorRunningDead), never deletes. Best-effort: a listing or +// per-record repair failure is logged and does not block the caller, since +// the stopped server is gone regardless and a later reconciliation pass +// will eventually repair any row this call missed. +func repairLiveStandaloneRecordsAfterOwnServerStopped(ctx context.Context, store staleExecutionRepairStore, log *logger.Logger) { + records, err := store.ListExecutorsRunningLiveStandalone(ctx) + if err != nil { + log.Warn("failed to list live standalone recovery-inventory records for immediate repair after stopping this installation's own control server", zap.Error(err)) + return + } + for _, record := range records { + if record == nil || record.SessionID == "" { + continue + } + if err := store.RepairExecutorRunningDead(ctx, record.SessionID); err != nil { + log.Warn("failed to repair recovery-inventory record after stopping this installation's own control server", + zap.String("session_id", record.SessionID), zap.Error(err)) + } + } +} + +// shutdownControlServerWithRetry issues the ownership-shutdown operation +// within the same bounded per-attempt timeout and retry count as +// AC-EXECUTORS-SURVIVAL-002.13 (AC-EXECUTORS-CONTROL-OWNERSHIP-004.7), using +// the operator-configured override when the caller supplies one (a zero +// timeout or negative retry count falls back to the AC's own two-second/ +// two-retry default, mirroring StandaloneExecutor.stopWithRetry). A +// dial-level connection-refused error counts as success without consuming a +// retry: AC-004.7's explicit "already stopping or already absent" carve-out, +// reached when the target has already exited on its own (e.g. via +// runUnownedReaper) before this call was ever issued. +func shutdownControlServerWithRetry(ctx context.Context, client AdoptionControlClient, timeout time.Duration, retries int) error { + if timeout <= 0 { + timeout = defaultRecoveryReadTimeout + } + if retries < 0 { + retries = defaultRecoveryReadRetries + } + + var lastErr error + for attempt := 0; attempt <= retries; attempt++ { + attemptCtx, cancel := context.WithTimeout(ctx, timeout) + lastErr = client.ShutdownControlServer(attemptCtx) + cancel() + if lastErr == nil || isAlreadyAbsentShutdownError(lastErr) { + return nil + } + } + return lastErr +} + +// isAlreadyAbsentShutdownError reports whether err is the dial-level +// connection-refused shape produced when the target control server has +// already exited before this call reached it. +func isAlreadyAbsentShutdownError(err error) bool { + return err != nil && strings.Contains(err.Error(), "connection refused") +} + +// withAdoptionRetry executes fn up to retries+1 times, each attempt bounded +// by its own per-attempt timeout derived from ctx rather than ctx's own +// (possibly absent) deadline -- the same bounded-per-attempt shape as +// shutdownControlServerWithRetry and StandaloneExecutor.listInstancesWithRetry +// (AC-EXECUTORS-SURVIVAL-002.13). A zero timeout or negative retry count +// falls back to the AC's own two-second/two-retry default. +func withAdoptionRetry[T any](ctx context.Context, timeout time.Duration, retries int, fn func(context.Context) (T, error)) (T, error) { + if timeout <= 0 { + timeout = defaultRecoveryReadTimeout + } + if retries < 0 { + retries = defaultRecoveryReadRetries + } + + var lastErr error + var zero T + for attempt := 0; attempt <= retries; attempt++ { + attemptCtx, cancel := context.WithTimeout(ctx, timeout) + result, err := fn(attemptCtx) + cancel() + if err == nil { + return result, nil + } + lastErr = err + } + return zero, lastErr +} + +// AdoptionControlClient is the subset of agentctl.ControlClient's ownership +// surface the adoption orchestration drives, narrowed so the decision below +// is testable without a real HTTP round trip. +type AdoptionControlClient interface { + SetAuthToken(token string) + GetIdentity(ctx context.Context) (*agentctl.IdentityInfo, error) + ProveOwnership(ctx context.Context, challenge string) ([]string, error) + GetServerDetails(ctx context.Context) (*agentctl.ServerDetails, error) + RotateCredential(ctx context.Context) (*agentctl.CredentialRotationResult, error) + ConfirmCredentialRotation(ctx context.Context, rotationID int64) error + ShutdownControlServer(ctx context.Context) error +} + +// AdoptionControlClientFactory builds a client targeting the recorded +// control endpoint. Injected so tests never need a real listener. +type AdoptionControlClientFactory func(endpoint string) (AdoptionControlClient, error) + +// AdoptionReason is a fixed, stable adoption-outcome label for observability +// (design 02: "adoption attempted, adopted, and refused with a reason drawn +// from a fixed set"). AC-EXECUTORS-CONTROL-OWNERSHIP-001.8's "no recorded +// endpoint, or nothing answers, or already shutting down" bucket is +// deliberately NOT a refusal reason (nothing about it was refused), so it +// gets its own value outside the AC-001.6 refusal-reason precedence list. +// +// AC-001.6's own refusal-reason list also names "no answer" as one of its six +// enumerated reasons, ahead of identity mismatch in its precedence order -- +// which reads as a refusal, in tension with AC-001.8 treating the identical +// case ("an endpoint that nothing answers") as no refusal at all, and with +// AC-EXECUTORS-CONTROL-OWNERSHIP-003.9 explicitly mapping its own +// shutting-down outcome onto AC-001.8's bucket rather than AC-001.6's. Two +// ACs describing this case as "not a refusal" against one phrase in a third +// naming it as one: AC-001.8 (reinforced by 003.9) is treated as the +// authoritative behavior, and AC-001.6's "no answer" is read as the same +// no_server bucket by another name, not a distinct recorded refusal reason. +type AdoptionReason string + +const ( + // AdoptionReasonNoServer covers "no recorded control endpoint", "the + // recorded endpoint answers nothing", and "the recorded endpoint reports + // it has already begun an unowned shutdown" alike (AC-001.8): none of + // these is a refusal, so none carries a refusal reason, and all three + // result in the same action, spawn fresh and report nothing recovered. + AdoptionReasonNoServer AdoptionReason = "no_server" + // AdoptionReasonIdentityMismatch covers a server that advertises no + // identity or no capability set, one whose identity differs from the + // recorded one, and one that cannot prove it holds the recorded + // credential (AC-001.4, AC-001.5) -- refused, left running untouched, + // never contacted again by this adoption attempt, and never sent the + // credential. + AdoptionReasonIdentityMismatch AdoptionReason = "identity_mismatch" + // AdoptionReasonCredentialUnavailable is the stored credential could not + // be retrieved (AC-001.9): refused before any authentication was + // attempted, left running untouched, its own unowned shutdown reclaims it. + AdoptionReasonCredentialUnavailable AdoptionReason = "credential_unavailable" + // AdoptionReasonAuthenticationFailed is an attempted rotation the server + // rejected -- including a rotate refused because the server has already + // begun an unowned shutdown after this attempt's identity read succeeded, + // which is intentionally not distinguished from a wrong credential here: + // both refuse without issuing a stop and take the identical spawn-fresh + // path, and only the logged reason label would differ. + AdoptionReasonAuthenticationFailed AdoptionReason = "authentication_failed" + // AdoptionReasonCapabilityIncompatible is an authenticated, identified, + // own server missing a required capability (AC-004.3/004.4): refused + // AND stopped, because only an own authenticated server may ever be + // stopped. + AdoptionReasonCapabilityIncompatible AdoptionReason = "capability_incompatible" + // AdoptionReasonCredentialRotationFailed is a rotation the server + // accepted whose durable storage (secret write or record write) did not + // complete (AC-002.5): adoption is incomplete, nothing further is issued + // to that server, and it is left exactly as it was left by the + // two-phase window -- still holding both the superseded and the new + // credential, unconfirmed. + AdoptionReasonCredentialRotationFailed AdoptionReason = "credential_rotation_failed" +) + +// AdoptionOutcome is the result of attempting to adopt a previously-detached +// control server. Endpoint and Credential are populated only when Adopted. +// ContactedAt is the instant this attempt first reached the recorded control +// endpoint (the GetIdentity call) -- the AC-EXECUTORS-SURVIVAL-003.7 recovery +// deadline's clock start. It is left zero when no live server was ever +// reached (no record, or the client/transport itself could not be built), +// since nothing recovery-relevant is running in that case. +type AdoptionOutcome struct { + Adopted bool + Reason AdoptionReason + Endpoint string + // Credential is the single credential that authenticates both + // control-plane and per-instance operations and streams (design 01 + // "Single driver", AC-EXECUTORS-CONTROL-OWNERSHIP-002.6): use it for + // every client built against this control server or any instance it + // supervises. + Credential string + ContactedAt time.Time + // UnownedPeriod is the adopted server's own resolved unowned period, read + // back from its /identity response (AC-EXECUTORS-CONTROL-OWNERSHIP-003.2/ + // .7). Only this value is safe to compute an ownership-renewal cadence + // from: this backend's local config can disagree with what the adopted + // process actually enforces across a restart that changed + // agentctl.unownedPeriod. Zero when the server answered without the + // field at all (a legacy pre-upgrade server) -- callers apply their own + // floor in that case, never a locally-resolved value. + UnownedPeriod time.Duration +} + +// AttemptAdoptControlServer implements startup steps 4 through 6 of design +// 02's "Startup" control flow: read the recorded control endpoint and +// identity, evaluate the gates in the required order -- home match, then +// authentication, then capability subset (AC-EXECUTORS-CONTROL-OWNERSHIP-001.3) +// -- and on a match rotate the ownership credential and persist the new +// record. It never spawns a process: the caller decides what to do with a +// refusal, which is always "spawn a fresh server on a free port and record +// it" regardless of which reason produced the refusal. +func AttemptAdoptControlServer( + ctx context.Context, + store AdoptionRecordStore, + secretStore secrets.SecretStore, + newClient AdoptionControlClientFactory, + homeDir string, + requiredCapabilities []string, + recoveryReadTimeout time.Duration, + recoveryReadRetries int, + log *logger.Logger, +) AdoptionOutcome { + record, err := store.GetControlServerRecord(ctx) + if err != nil { + return AdoptionOutcome{Reason: AdoptionReasonNoServer} + } + + client, err := newClient(record.Endpoint) + if err != nil { + return AdoptionOutcome{Reason: AdoptionReasonNoServer} + } + + // The AC-EXECUTORS-SURVIVAL-003.7 recovery deadline's clock starts here, + // at the first real contact with the recorded control endpoint -- + // regardless of which gate below ultimately refuses or accepts adoption. + contactedAt := time.Now() + identity, err := withAdoptionRetry(ctx, recoveryReadTimeout, recoveryReadRetries, client.GetIdentity) + if err != nil { + return AdoptionOutcome{Reason: AdoptionReasonNoServer} + } + + if !identityMatchesRecordedServer(identity, record) || len(identity.Capabilities) == 0 { + return AdoptionOutcome{Reason: AdoptionReasonIdentityMismatch, ContactedAt: contactedAt} + } + + credential, err := revealControlServerCredential(ctx, secretStore, record.CredentialSecretID) + if err != nil { + return AdoptionOutcome{Reason: AdoptionReasonCredentialUnavailable, ContactedAt: contactedAt} + } + + if !controlServerHoldsCredential(ctx, client, credential, homeDir, recoveryReadTimeout, recoveryReadRetries) { + return AdoptionOutcome{Reason: AdoptionReasonIdentityMismatch, ContactedAt: contactedAt} + } + + client.SetAuthToken(credential) + rotated, err := withAdoptionRetry(ctx, recoveryReadTimeout, recoveryReadRetries, client.RotateCredential) + if err != nil { + return AdoptionOutcome{Reason: AdoptionReasonAuthenticationFailed, ContactedAt: contactedAt} + } + + if !capabilitiesSatisfy(requiredCapabilities, identity.Capabilities) { + client.SetAuthToken(rotated.Credential) + return stopIncompatibleControlServer(ctx, client, store, record, recoveryReadTimeout, recoveryReadRetries, contactedAt, log) + } + + client.SetAuthToken(rotated.Credential) + return finalizeControlServerAdoption(ctx, finalizeAdoptionArgs{ + store: store, + secretStore: secretStore, + client: client, + record: record, + identity: identity, + rotated: rotated, + contactedAt: contactedAt, + recoveryReadTimeout: recoveryReadTimeout, + recoveryReadRetries: recoveryReadRetries, + log: log, + }) +} + +// finalizeAdoptionArgs carries the state the two durable writes and the +// confirmation call need, so the adoption gates above stay readable as a +// sequence of refusals. +type finalizeAdoptionArgs struct { + store AdoptionRecordStore + secretStore secrets.SecretStore + client AdoptionControlClient + record *models.ControlServerRecord + identity *agentctl.IdentityInfo + rotated *agentctl.CredentialRotationResult + contactedAt time.Time + recoveryReadTimeout time.Duration + recoveryReadRetries int + log *logger.Logger +} + +// finalizeControlServerAdoption performs the two durable writes adoption +// depends on -- the rotated credential and the updated record -- and only +// then tells the server the rotation is confirmed. Either write failing +// leaves adoption incomplete, because confirming a credential this backend +// did not durably record would tell the server to drop the one credential +// still recoverable from durable state. +func finalizeControlServerAdoption(ctx context.Context, a finalizeAdoptionArgs) AdoptionOutcome { + secretID, err := storeControlServerCredential(ctx, a.secretStore, a.record.CredentialSecretID, a.rotated.Credential) + if err != nil { + return AdoptionOutcome{Reason: AdoptionReasonCredentialRotationFailed, ContactedAt: a.contactedAt} + } + + updated := &models.ControlServerRecord{ + Endpoint: a.record.Endpoint, + ServerIdentity: a.identity.ServerIdentity, + CredentialSecretID: secretID, + Capabilities: a.identity.Capabilities, + DiagnosticLogPath: recordedDiagnosticLogPath(ctx, a.client, a.recoveryReadTimeout, a.recoveryReadRetries), + CreatedAt: a.record.CreatedAt, + } + if err := a.store.UpsertControlServerRecord(ctx, updated); err != nil { + return AdoptionOutcome{Reason: AdoptionReasonCredentialRotationFailed, ContactedAt: a.contactedAt} + } + + // Sent only after both durable writes above completed + // (AC-EXECUTORS-CONTROL-OWNERSHIP-002.7). A failure here is not treated + // as fatal to adoption: the durable state already names the rotated + // credential as current, so a future retry or the server's own + // idempotent replay (AC-002.10) recovers it, and the superseded + // credential merely stays adoption-only-acceptable a little longer than + // necessary in the meantime. + if _, err := withAdoptionRetry(ctx, a.recoveryReadTimeout, a.recoveryReadRetries, func(c context.Context) (struct{}, error) { + return struct{}{}, a.client.ConfirmCredentialRotation(c, a.rotated.RotationID) + }); err != nil { + a.log.Warn("control server credential rotation stored durably but confirmation call failed", + zap.String("endpoint", a.record.Endpoint), zap.Error(err)) + } + + return AdoptionOutcome{ + Adopted: true, + Endpoint: a.record.Endpoint, + Credential: a.rotated.Credential, + ContactedAt: a.contactedAt, + UnownedPeriod: time.Duration(a.identity.UnownedPeriodMS) * time.Millisecond, + } +} + +// RecordFreshControlServer durably records a freshly spawned (not adopted) +// control server as the new installation-scoped record. Per the failure +// table's "Own server started after a refused or failed adoption" row, the +// single control-server record is always rewritten to name the new server: +// the record cannot keep pointing at a server that is about to reap itself +// through its own unowned shutdown. credential is the server's initial +// ownership credential (the bootstrap auth token minted at launch). A +// prior record's CredentialSecretID, when one is readable, is reused so a +// spawn-after-refused-adoption does not leave an orphaned secret row behind. +func RecordFreshControlServer( + ctx context.Context, + store AdoptionRecordStore, + secretStore secrets.SecretStore, + client AdoptionControlClient, + endpoint string, + credential string, +) error { + identity, err := client.GetIdentity(ctx) + if err != nil { + return fmt.Errorf("read freshly spawned control server identity: %w", err) + } + + var priorSecretID string + if prior, err := store.GetControlServerRecord(ctx); err == nil { + priorSecretID = prior.CredentialSecretID + } + + secretID, err := storeControlServerCredential(ctx, secretStore, priorSecretID, credential) + if err != nil { + return fmt.Errorf("store freshly spawned control server credential: %w", err) + } + + record := &models.ControlServerRecord{ + Endpoint: endpoint, + ServerIdentity: identity.ServerIdentity, + CredentialSecretID: secretID, + Capabilities: identity.Capabilities, + DiagnosticLogPath: recordedDiagnosticLogPath(ctx, client, defaultRecoveryReadTimeout, defaultRecoveryReadRetries), + } + return store.UpsertControlServerRecord(ctx, record) +} + +// ReclaimUnneededControlServer implements AC-EXECUTORS-SURVIVAL-005.5: when +// the agent-survival capability is disabled, a detached control server left +// running by an earlier survival-enabled launch is stopped rather than left +// running unowned -- but only when this backend can prove that server is +// its own by the same identity+credential test AttemptAdoptControlServer +// applies to adoption. A server this backend cannot prove is its own is +// left untouched, and so is one that answers nothing (nothing left to +// reclaim) or has no recorded endpoint at all. +// +// This never adopts: it does not rotate the credential, so a backend +// holding only a superseded credential can still perform it, which +// AC-EXECUTORS-SURVIVAL-005.1 forbids adoption itself from doing. The stop +// is issued as the ownership-shutdown operation of +// AC-EXECUTORS-CONTROL-OWNERSHIP-002.9, which requires no prior adoption or +// rotation for exactly this reason. Every failure mode here is logged and +// non-fatal to startup: the caller always falls through to spawning a fresh +// server regardless of what this reclaim attempt did. +func ReclaimUnneededControlServer( + ctx context.Context, + store AdoptionRecordStore, + secretStore secrets.SecretStore, + newClient AdoptionControlClientFactory, + homeDir string, + recoveryReadTimeout time.Duration, + recoveryReadRetries int, + log *logger.Logger, +) { + record, err := store.GetControlServerRecord(ctx) + if err != nil { + return + } + + client, err := newClient(record.Endpoint) + if err != nil { + return + } + + identity, err := client.GetIdentity(ctx) + if err != nil { + return + } + if !identityMatchesRecordedServer(identity, record) { + return + } + + credential, err := revealControlServerCredential(ctx, secretStore, record.CredentialSecretID) + if err != nil { + log.Warn("cannot reclaim detached control server: stored credential unavailable", + zap.String("endpoint", record.Endpoint), zap.Error(err)) + return + } + + if !controlServerHoldsCredential(ctx, client, credential, homeDir, recoveryReadTimeout, recoveryReadRetries) { + log.Warn("cannot reclaim detached control server: it could not prove it holds this installation's credential", + zap.String("endpoint", record.Endpoint)) + return + } + + client.SetAuthToken(credential) + if err := shutdownControlServerWithRetry(ctx, client, recoveryReadTimeout, recoveryReadRetries); err != nil { + log.Warn("failed to stop detached control server left by an earlier survival-enabled launch after exhausting retries; leaving it to its own unowned shutdown", + zap.String("endpoint", record.Endpoint), zap.Error(err)) + return + } + + log.Info("stopped a detached control server left by an earlier survival-enabled launch now that the capability is disabled", + zap.String("endpoint", record.Endpoint)) + if repairStore, ok := store.(staleExecutionRepairStore); ok { + repairLiveStandaloneRecordsAfterOwnServerStopped(ctx, repairStore, log) + } +} + +// stopIncompatibleControlServer stops an authenticated, identified, own +// server that is missing a required capability and, on success, repairs its +// live standalone records immediately (AC-EXECUTORS-CONTROL-OWNERSHIP-004.3). +// Always returns the AdoptionReasonCapabilityIncompatible refusal regardless +// of whether the stop itself succeeded. +func stopIncompatibleControlServer( + ctx context.Context, + client AdoptionControlClient, + store AdoptionRecordStore, + record *models.ControlServerRecord, + recoveryReadTimeout time.Duration, + recoveryReadRetries int, + contactedAt time.Time, + log *logger.Logger, +) AdoptionOutcome { + if stopErr := shutdownControlServerWithRetry(ctx, client, recoveryReadTimeout, recoveryReadRetries); stopErr != nil { + // AC-EXECUTORS-CONTROL-OWNERSHIP-004.7: retries exhausted -- record no + // repair, report no recovered instances (already true, Adopted stays + // false below), and leave the server to its own unowned shutdown; a + // later backend repairs through the deferred + // AC-EXECUTORS-CONTROL-OWNERSHIP-003.5 path once it stops itself. + log.Warn("failed to stop incompatible control server after exhausting retries; leaving it to its own unowned shutdown", + zap.String("endpoint", record.Endpoint), zap.Error(stopErr)) + } else if repairStore, ok := store.(staleExecutionRepairStore); ok { + // AC-EXECUTORS-CONTROL-OWNERSHIP-004.3: the stop succeeded and a + // backend is attached, so repair every live standalone record + // immediately rather than deferring to the next start. + repairLiveStandaloneRecordsAfterOwnServerStopped(ctx, repairStore, log) + } + return AdoptionOutcome{Reason: AdoptionReasonCapabilityIncompatible, ContactedAt: contactedAt} +} + +// identityMatchesRecordedServer reports whether a live server's per-launch +// identity nonce is the exact one the control-server record carries, which is +// what proves the live server IS the recorded process rather than merely +// another launch. Installation identity (the home directory) is a separate +// question, proved over the authenticated ownership-details exchange rather +// than here, because /identity deliberately discloses no filesystem path +// (AC-EXECUTORS-CONTROL-OWNERSHIP-001.11). An identity either side cannot +// produce leaves the match unprovable and is refused rather than assumed. +func identityMatchesRecordedServer(identity *agentctl.IdentityInfo, record *models.ControlServerRecord) bool { + if record.ServerIdentity == "" || identity.ServerIdentity != record.ServerIdentity { + return false + } + return true +} + +func capabilitiesSatisfy(required, advertised []string) bool { + have := make(map[string]struct{}, len(advertised)) + for _, c := range advertised { + have[c] = struct{}{} + } + for _, r := range required { + if _, ok := have[r]; !ok { + return false + } + } + return true +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/control_server_adoption_identity_test.go b/apps/backend/internal/agent/runtime/lifecycle/control_server_adoption_identity_test.go new file mode 100644 index 0000000000..7cee6ce80e --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/control_server_adoption_identity_test.go @@ -0,0 +1,43 @@ +package lifecycle + +import ( + "testing" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/task/models" +) + +// TestIdentityMatchesRecordedServerRequiresRecordedIdentity pins the closed +// side of the identity proof. A record with no recorded server identity cannot +// prove which process it describes, and an installation-wide value such as the +// home directory proves nothing beyond a shared installation, which every +// control server on the machine satisfies. An unprovable identity is refused +// rather than adopted. +func TestIdentityMatchesRecordedServerRequiresRecordedIdentity(t *testing.T) { + identity := &agentctl.IdentityInfo{ServerIdentity: "live-nonce"} + + if identityMatchesRecordedServer(identity, &models.ControlServerRecord{}) { + t.Fatal("a record carrying no server identity was treated as proven") + } + + matching := &models.ControlServerRecord{ServerIdentity: "live-nonce"} + if !identityMatchesRecordedServer(identity, matching) { + t.Fatal("a record whose server identity matches the live one was refused") + } + + other := &models.ControlServerRecord{ServerIdentity: "some-other-nonce"} + if identityMatchesRecordedServer(identity, other) { + t.Fatal("a record whose server identity differs from the live one was accepted") + } +} + +// TestIdentityMatchesRecordedServerRequiresAdvertisedIdentity covers the other +// half: a live server that advertises no identity is equally unprovable, so it +// cannot satisfy a record that names one. +func TestIdentityMatchesRecordedServerRequiresAdvertisedIdentity(t *testing.T) { + record := &models.ControlServerRecord{ServerIdentity: "recorded-nonce"} + + if identityMatchesRecordedServer(&agentctl.IdentityInfo{}, record) { + t.Fatal("a server advertising no identity was treated as proven") + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/control_server_adoption_precedence_test.go b/apps/backend/internal/agent/runtime/lifecycle/control_server_adoption_precedence_test.go new file mode 100644 index 0000000000..939fb7199e --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/control_server_adoption_precedence_test.go @@ -0,0 +1,61 @@ +package lifecycle + +import ( + "context" + "errors" + "testing" +) + +// TestAttemptAdoptControlServerIdentityMismatchTakesPrecedenceOverCredentialUnavailable +// pins AC-001.6's refusal-reason precedence order: identity mismatch is +// evaluated before credential unavailability, so when both conditions hold -- +// here, a mismatched ServerIdentity AND a CredentialSecretID the secret store +// has no row for -- the recorded reason is identity_mismatch, not +// credential_unavailable, because the credential-reveal gate is never +// reached. +func TestAttemptAdoptControlServerIdentityMismatchTakesPrecedenceOverCredentialUnavailable(t *testing.T) { + record := validRecord() + record.ServerIdentity = "recorded-identity" + record.CredentialSecretID = "does-not-exist" + identity := validIdentity() + identity.ServerIdentity = "a-different-live-identity" + client := &fakeAdoptionControlClient{identity: identity} + store, factory := adoptionFixture(t, record, client) + + outcome := AttemptAdoptControlServer(context.Background(), store, newInMemorySecretStore(), factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted || outcome.Reason != AdoptionReasonIdentityMismatch { + t.Fatalf("outcome = %+v, want unadopted identity_mismatch even though credential_unavailable also applies", outcome) + } + if len(client.presentedTokens) != 0 { + t.Fatal("RotateCredential's SetAuthToken was reached despite an identity mismatch") + } +} + +// TestAttemptAdoptControlServerCredentialUnavailableTakesPrecedenceOverAuthenticationFailed +// pins AC-001.6's refusal-reason precedence order: credential unavailability +// is evaluated before authentication failure, so when both conditions hold -- +// here, a CredentialSecretID the secret store has no row for AND a client +// that would refuse any rotation attempt -- the recorded reason is +// credential_unavailable, not authentication_failed, because RotateCredential +// is never called. +func TestAttemptAdoptControlServerCredentialUnavailableTakesPrecedenceOverAuthenticationFailed(t *testing.T) { + record := validRecord() + record.CredentialSecretID = "does-not-exist" + client := &fakeAdoptionControlClient{ + identity: validIdentity(), + rotateErr: errors.New("401 invalid auth token"), + } + store, factory := adoptionFixture(t, record, client) + + outcome := AttemptAdoptControlServer(context.Background(), store, newInMemorySecretStore(), factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted || outcome.Reason != AdoptionReasonCredentialUnavailable { + t.Fatalf("outcome = %+v, want unadopted credential_unavailable even though authentication_failed also applies", outcome) + } + if len(client.presentedTokens) != 0 { + t.Fatal("RotateCredential's SetAuthToken was reached despite no credential being available") + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/control_server_adoption_store_failure_test.go b/apps/backend/internal/agent/runtime/lifecycle/control_server_adoption_store_failure_test.go new file mode 100644 index 0000000000..8cd31df25b --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/control_server_adoption_store_failure_test.go @@ -0,0 +1,68 @@ +package lifecycle + +import ( + "context" + "errors" + "testing" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" +) + +// TestAdoptionTreatsAnUnreadableRecordAsNoServer pins the durable-read +// failure branch. A store that cannot answer is not evidence that a control +// server is absent, but it is also not a refusal: there is nothing to refuse +// yet. Adoption takes the same spawn-fresh path as a genuinely empty record, +// and in particular never contacts an endpoint it could not read. +func TestAdoptionTreatsAnUnreadableRecordAsNoServer(t *testing.T) { + store := &fakeAdoptionRecordStore{getErr: errors.New("database is locked")} + factory := func(string) (AdoptionControlClient, error) { + t.Fatal("a client was built for a record that could not be read") + return nil, nil + } + + outcome := AttemptAdoptControlServer(context.Background(), store, newInMemorySecretStore(), factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted { + t.Fatalf("outcome = %+v, want unadopted", outcome) + } + if outcome.Reason != AdoptionReasonNoServer { + t.Fatalf("Reason = %q, want %q", outcome.Reason, AdoptionReasonNoServer) + } +} + +// TestAdoptionIsIncompleteWhenTheRecordWriteFails is the sibling of the +// secret-write failure. The rotation succeeded on the server, so the server +// now holds a credential this backend could not durably record. Confirming +// it would tell the server to drop the superseded credential, which is the +// only one still recoverable from durable state, stranding the server +// permanently. Adoption is therefore incomplete and confirm is never sent. +func TestAdoptionIsIncompleteWhenTheRecordWriteFails(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "current-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + client := &fakeAdoptionControlClient{ + identity: validIdentity(), + heldCredential: "current-token", + rotateResult: &agentctl.CredentialRotationResult{RotationID: 9, Credential: "rotated-token"}, + } + store, factory := adoptionFixture(t, record, client) + store.putErr = errors.New("database is locked") + + outcome := AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted || outcome.Reason != AdoptionReasonCredentialRotationFailed { + t.Fatalf("outcome = %+v, want unadopted credential_rotation_failed", outcome) + } + if client.confirmCalled { + t.Fatal("ConfirmCredentialRotation was sent despite the record write failing") + } + if len(store.upserts) != 0 { + t.Fatalf("upserts = %+v, want none recorded when the write failed", store.upserts) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/control_server_adoption_test.go b/apps/backend/internal/agent/runtime/lifecycle/control_server_adoption_test.go new file mode 100644 index 0000000000..dd6eb45dcd --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/control_server_adoption_test.go @@ -0,0 +1,952 @@ +package lifecycle + +import ( + "context" + "errors" + "testing" + "time" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/common/ownershipproof" + "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/task/models" +) + +// failingUpdateSecretStore fails Update for one specific secret ID, so a +// test can force the durable-storage failure branch of adoption's rotation +// finalize step without the fallback-to-create path in +// storeControlServerCredential silently absorbing it. +type failingUpdateSecretStore struct { + *inMemorySecretStore + failUpdateFor string +} + +func (s *failingUpdateSecretStore) Update(ctx context.Context, id string, req *secrets.UpdateSecretRequest) error { + if id == s.failUpdateFor { + return errors.New("injected secret update failure") + } + return s.inMemorySecretStore.Update(ctx, id, req) +} + +func newAdoptionTestLogger(t *testing.T) *logger.Logger { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("new logger: %v", err) + } + return log +} + +// fakeAdoptionRecordStore is an in-memory AdoptionRecordStore double that +// also implements the optional staleExecutionRepairStore capability, so +// tests can observe the immediate-repair step of AC-EXECUTORS-CONTROL- +// OWNERSHIP-004.3/AC-EXECUTORS-SURVIVAL-005.5. +type fakeAdoptionRecordStore struct { + record *models.ControlServerRecord + getErr error + upserts []*models.ControlServerRecord + putErr error + + liveStandaloneRecords []*models.ExecutorRunning + listLiveErr error + repairedSessionIDs []string + repairErr error +} + +func (f *fakeAdoptionRecordStore) ListExecutorsRunningLiveStandalone(context.Context) ([]*models.ExecutorRunning, error) { + if f.listLiveErr != nil { + return nil, f.listLiveErr + } + return f.liveStandaloneRecords, nil +} + +func (f *fakeAdoptionRecordStore) RepairExecutorRunningDead(_ context.Context, sessionID string) error { + if f.repairErr != nil { + return f.repairErr + } + f.repairedSessionIDs = append(f.repairedSessionIDs, sessionID) + return nil +} + +func (f *fakeAdoptionRecordStore) GetControlServerRecord(context.Context) (*models.ControlServerRecord, error) { + if f.getErr != nil { + return nil, f.getErr + } + if f.record == nil { + return nil, models.ErrControlServerRecordNotFound + } + return f.record, nil +} + +func (f *fakeAdoptionRecordStore) UpsertControlServerRecord(_ context.Context, record *models.ControlServerRecord) error { + if f.putErr != nil { + return f.putErr + } + f.upserts = append(f.upserts, record) + return nil +} + +// fakeAdoptionControlClient is an in-memory AdoptionControlClient double. +// +// heldCredential and boundHome are what this double's server actually +// possesses: it answers an ownership challenge by deriving from them, so a +// double whose held credential differs from the one the backend reveals, or +// whose bound home differs from the backend's, cannot produce an acceptable +// proof -- which is exactly the position a foreign control server is in. +// boundHome defaults to testHomeDir, so a double only has to name it when +// the point of the test is that the two installations differ. +type fakeAdoptionControlClient struct { + identity *agentctl.IdentityInfo + identityErr error + heldCredential string + boundHome string + proveErr error + challenges []string + rotateResult *agentctl.CredentialRotationResult + rotateErr error + confirmErr error + shutdownErr error + shutdownCalled bool + confirmedID int64 + confirmCalled bool + presentedTokens []string +} + +func (f *fakeAdoptionControlClient) SetAuthToken(token string) { + f.presentedTokens = append(f.presentedTokens, token) +} + +func (f *fakeAdoptionControlClient) GetIdentity(context.Context) (*agentctl.IdentityInfo, error) { + return f.identity, f.identityErr +} + +func (f *fakeAdoptionControlClient) ProveOwnership(_ context.Context, challenge string) ([]string, error) { + f.challenges = append(f.challenges, challenge) + if f.proveErr != nil { + return nil, f.proveErr + } + home := f.boundHome + if home == "" { + home = testHomeDir + } + return []string{ownershipproof.Derive(f.heldCredential, challenge, home)}, nil +} + +func (f *fakeAdoptionControlClient) GetServerDetails(context.Context) (*agentctl.ServerDetails, error) { + return &agentctl.ServerDetails{ + HomeDir: testHomeDir, + DiagnosticLogPath: testDiagnosticLogPath, + }, nil +} + +func (f *fakeAdoptionControlClient) RotateCredential(context.Context) (*agentctl.CredentialRotationResult, error) { + return f.rotateResult, f.rotateErr +} + +func (f *fakeAdoptionControlClient) ConfirmCredentialRotation(_ context.Context, rotationID int64) error { + f.confirmCalled = true + f.confirmedID = rotationID + return f.confirmErr +} + +func (f *fakeAdoptionControlClient) ShutdownControlServer(context.Context) error { + f.shutdownCalled = true + return f.shutdownErr +} + +const ( + testHomeDir = "/home/kandev" + testDiagnosticLogPath = "/home/kandev/logs/agentctl-diagnostic.log" +) + +func validRecord() *models.ControlServerRecord { + return &models.ControlServerRecord{ + Endpoint: "127.0.0.1:9999", + ServerIdentity: "matching-identity", + CredentialSecretID: "secret-1", + Capabilities: []string{"agent-survival.v1"}, + DiagnosticLogPath: testDiagnosticLogPath, + } +} + +func validIdentity() *agentctl.IdentityInfo { + return &agentctl.IdentityInfo{ + ServerIdentity: "matching-identity", + Capabilities: []string{"agent-survival.v1"}, + UnownedPeriodMS: 300000, + } +} + +func adoptionFixture(t *testing.T, record *models.ControlServerRecord, client *fakeAdoptionControlClient) (*fakeAdoptionRecordStore, func(string) (AdoptionControlClient, error)) { + t.Helper() + store := &fakeAdoptionRecordStore{record: record} + factory := func(string) (AdoptionControlClient, error) { return client, nil } + return store, factory +} + +// TestAttemptAdoptControlServerNoRecordSpawnsFresh pins +// AC-EXECUTORS-CONTROL-OWNERSHIP-001.8's "no recorded control endpoint" +// branch: nothing to adopt, no refusal reason, caller spawns fresh. +func TestAttemptAdoptControlServerNoRecordSpawnsFresh(t *testing.T) { + store := &fakeAdoptionRecordStore{} + factory := func(string) (AdoptionControlClient, error) { + t.Fatal("client factory must not be called with no record") + return nil, nil + } + + outcome := AttemptAdoptControlServer(context.Background(), store, newInMemorySecretStore(), factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted { + t.Fatal("Adopted = true, want false") + } + if outcome.Reason != AdoptionReasonNoServer { + t.Fatalf("Reason = %q, want %q", outcome.Reason, AdoptionReasonNoServer) + } +} + +// TestAttemptAdoptControlServerUnreachableSpawnsFresh pins the "recorded +// endpoint answers nothing" branch of AC-001.8, folded into the same +// no-refusal reason as no-record. +func TestAttemptAdoptControlServerUnreachableSpawnsFresh(t *testing.T) { + client := &fakeAdoptionControlClient{identityErr: errors.New("connection refused")} + store, factory := adoptionFixture(t, validRecord(), client) + + outcome := AttemptAdoptControlServer(context.Background(), store, newInMemorySecretStore(), factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted || outcome.Reason != AdoptionReasonNoServer { + t.Fatalf("outcome = %+v, want unadopted no_server", outcome) + } +} + +// blockingIdentityControlClient's GetIdentity blocks until its call's own +// context is done, for proving a stalled control server cannot hold +// AttemptAdoptControlServer open past its configured per-attempt timeout. +type blockingIdentityControlClient struct { + fakeAdoptionControlClient + identityCalls int +} + +func (c *blockingIdentityControlClient) GetIdentity(ctx context.Context) (*agentctl.IdentityInfo, error) { + c.identityCalls++ + <-ctx.Done() + return nil, ctx.Err() +} + +// TestAttemptAdoptControlServerGetIdentityBoundedByConfiguredTimeout pins +// Review round 5 finding 2: a stalled control server's GetIdentity call must +// not hold AttemptAdoptControlServer open past the configured +// recoveryReadTimeout -- it must not fall back to the HTTP client's own much +// larger internal timeout (or block forever on an undeadlined context). +func TestAttemptAdoptControlServerGetIdentityBoundedByConfiguredTimeout(t *testing.T) { + client := &blockingIdentityControlClient{} + store, _ := adoptionFixture(t, validRecord(), &client.fakeAdoptionControlClient) + factory := func(string) (AdoptionControlClient, error) { return client, nil } + + start := time.Now() + outcome := AttemptAdoptControlServer(context.Background(), store, newInMemorySecretStore(), factory, + testHomeDir, RequiredSurvivalCapabilities, 20*time.Millisecond, 0, newAdoptionTestLogger(t)) + elapsed := time.Since(start) + + if outcome.Adopted || outcome.Reason != AdoptionReasonNoServer { + t.Fatalf("outcome = %+v, want unadopted no_server (a timed-out identity read never answered)", outcome) + } + if elapsed > 500*time.Millisecond { + t.Fatalf("AttemptAdoptControlServer took %v, want bounded by the configured 20ms per-attempt timeout, not an unbounded block", elapsed) + } + if client.identityCalls == 0 { + t.Fatal("expected GetIdentity to have been attempted at least once") + } +} + +// TestAttemptAdoptControlServerHomeMismatchRefusesWithoutStop pins AC-001.4: +// a server belonging to a different installation is refused, and -- +// critically -- ShutdownControlServer is never called, because a process +// this installation cannot prove is its own is never stopped. The mismatch +// is not something the server reports about itself; it falls out of the +// proof, which binds the home directory, so a server bound to another home +// cannot answer this backend's challenge. +func TestAttemptAdoptControlServerHomeMismatchRefusesWithoutStop(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "own-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + client := &fakeAdoptionControlClient{ + identity: validIdentity(), + heldCredential: "own-token", + boundHome: "/home/someone-else", + } + store, factory := adoptionFixture(t, record, client) + + outcome := AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted || outcome.Reason != AdoptionReasonIdentityMismatch { + t.Fatalf("outcome = %+v, want unadopted identity_mismatch", outcome) + } + if client.shutdownCalled { + t.Fatal("ShutdownControlServer was called for a home-mismatched server") + } + if len(client.presentedTokens) != 0 { + t.Fatalf("credential was sent to a server bound to another home: %v", client.presentedTokens) + } +} + +// TestAttemptAdoptControlServerNoCapabilitiesAdvertisedIsIdentityMismatch +// pins AC-001.5: a server that advertises no capability set at all is +// treated as AC-001.4, not as a capability-incompatible server -- so it is +// never stopped either. +func TestAttemptAdoptControlServerNoCapabilitiesAdvertisedIsIdentityMismatch(t *testing.T) { + identity := validIdentity() + identity.Capabilities = nil + client := &fakeAdoptionControlClient{identity: identity} + store, factory := adoptionFixture(t, validRecord(), client) + + outcome := AttemptAdoptControlServer(context.Background(), store, newInMemorySecretStore(), factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Reason != AdoptionReasonIdentityMismatch { + t.Fatalf("Reason = %q, want %q", outcome.Reason, AdoptionReasonIdentityMismatch) + } + if client.shutdownCalled { + t.Fatal("ShutdownControlServer was called for a server advertising no capabilities") + } +} + +// TestAttemptAdoptControlServerCredentialUnavailableRefusesWithoutStop pins +// AC-001.9: the stored credential cannot be retrieved (here, a stale +// reference the fake secret store has no row for), so no authentication is +// even attempted, the server is left running, and RotateCredential is never +// called. +func TestAttemptAdoptControlServerCredentialUnavailableRefusesWithoutStop(t *testing.T) { + client := &fakeAdoptionControlClient{identity: validIdentity()} + record := validRecord() + record.CredentialSecretID = "does-not-exist" + store, factory := adoptionFixture(t, record, client) + + outcome := AttemptAdoptControlServer(context.Background(), store, newInMemorySecretStore(), factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted || outcome.Reason != AdoptionReasonCredentialUnavailable { + t.Fatalf("outcome = %+v, want unadopted credential_unavailable", outcome) + } + if client.shutdownCalled { + t.Fatal("ShutdownControlServer was called on a credential-unavailable refusal") + } + if len(client.presentedTokens) != 0 { + t.Fatal("RotateCredential's SetAuthToken was reached despite no credential being available") + } +} + +// TestAttemptAdoptControlServerAuthenticationFailureRefusesWithoutStop pins +// AC-001.4's authentication-failure branch: rotate is attempted and refused +// by the server, so this backend treats it as foreign and never stops it. +func TestAttemptAdoptControlServerAuthenticationFailureRefusesWithoutStop(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "stale-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + client := &fakeAdoptionControlClient{ + heldCredential: "stale-token", + identity: validIdentity(), + rotateErr: errors.New("401 invalid auth token"), + } + store, factory := adoptionFixture(t, record, client) + + outcome := AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted || outcome.Reason != AdoptionReasonAuthenticationFailed { + t.Fatalf("outcome = %+v, want unadopted authentication_failed", outcome) + } + if client.shutdownCalled { + t.Fatal("ShutdownControlServer was called on an authentication-failure refusal") + } +} + +// TestAttemptAdoptControlServerIncompatibleCapabilityStopsSurvivor pins +// AC-004.3/004.4: an own, authenticated server missing a required +// capability is refused AND stopped -- the one refusal reason that DOES +// issue a stop, because only an own authenticated server may ever be +// stopped. +func TestAttemptAdoptControlServerIncompatibleCapabilityStopsSurvivor(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "current-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + identity := validIdentity() + identity.Capabilities = []string{"some-other-capability"} + client := &fakeAdoptionControlClient{ + heldCredential: "current-token", + identity: identity, + rotateResult: &agentctl.CredentialRotationResult{RotationID: 7, Credential: "rotated-token"}, + } + store, factory := adoptionFixture(t, record, client) + store.liveStandaloneRecords = []*models.ExecutorRunning{ + {SessionID: "session-1"}, {SessionID: "session-2"}, + } + + outcome := AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted || outcome.Reason != AdoptionReasonCapabilityIncompatible { + t.Fatalf("outcome = %+v, want unadopted capability_incompatible", outcome) + } + if !client.shutdownCalled { + t.Fatal("ShutdownControlServer was not called for an incompatible own server") + } + if len(store.upserts) != 0 { + t.Fatal("control server record was rewritten by the refused adoption itself; that is the caller's job after spawning fresh") + } + // AC-EXECUTORS-CONTROL-OWNERSHIP-004.3 (Review round 1, finding 4): a + // successful stop of a confirmed-own server immediately repairs every + // live standalone recovery-inventory record, since exactly one control + // server exists per installation and this one just proved it was that + // server. + if len(store.repairedSessionIDs) != 2 { + t.Fatalf("repaired sessions = %v, want session-1 and session-2 repaired immediately after the successful stop", store.repairedSessionIDs) + } +} + +// TestAttemptAdoptControlServerIncompatibleCapabilityRetriesStopBeforeGivingUp +// pins AC-EXECUTORS-CONTROL-OWNERSHIP-004.7: the stop this branch issues is +// retried within a bounded budget, not a single unretried call, and no +// record is repaired when every retry fails. The injected failure is a +// genuinely retryable server error, not a connection-refused/dial failure -- +// AC-004.7's "already absent counts as success" carve-out means that shape +// resolves on the first attempt instead of exhausting retries (see +// TestAttemptAdoptControlServerIncompatibleCapabilityConnectionRefusedCountsAsSuccess). +func TestAttemptAdoptControlServerIncompatibleCapabilityRetriesStopBeforeGivingUp(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "current-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + identity := validIdentity() + identity.Capabilities = []string{"some-other-capability"} + client := &countingShutdownControlClient{ + fakeAdoptionControlClient: fakeAdoptionControlClient{ + heldCredential: "current-token", + identity: identity, + rotateResult: &agentctl.CredentialRotationResult{RotationID: 7, Credential: "rotated-token"}, + shutdownErr: errors.New("500 internal server error"), + }, + } + store, _ := adoptionFixture(t, record, &client.fakeAdoptionControlClient) + factory := func(string) (AdoptionControlClient, error) { return client, nil } + store.liveStandaloneRecords = []*models.ExecutorRunning{{SessionID: "session-1"}} + + outcome := AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted || outcome.Reason != AdoptionReasonCapabilityIncompatible { + t.Fatalf("outcome = %+v, want unadopted capability_incompatible", outcome) + } + if client.shutdownAttempts < 2 { + t.Fatalf("shutdown attempts = %d, want more than one (bounded retry, not a single unretried call)", client.shutdownAttempts) + } + if len(store.repairedSessionIDs) != 0 { + t.Fatalf("repaired sessions = %v, want none: AC-004.7 forbids repair when the stop's retries are exhausted", store.repairedSessionIDs) + } +} + +// countingShutdownControlClient wraps fakeAdoptionControlClient to count +// ShutdownControlServer attempts, for asserting a retry budget was actually +// exercised rather than a single call. +type countingShutdownControlClient struct { + fakeAdoptionControlClient + shutdownAttempts int +} + +func (c *countingShutdownControlClient) ShutdownControlServer(ctx context.Context) error { + c.shutdownAttempts++ + return c.fakeAdoptionControlClient.ShutdownControlServer(ctx) +} + +// TestAttemptAdoptControlServerIncompatibleCapabilityConnectionRefusedCountsAsSuccess +// pins AC-EXECUTORS-CONTROL-OWNERSHIP-004.7's "a control server reporting +// that it is already stopping or already absent shall count as success" +// carve-out: a dial-level connection-refused error (the shape produced when +// the target already exited on its own, e.g. via its own unowned reaper) +// must not be retried to exhaustion and reported as a genuine failure -- the +// immediate-repair step must still run. +func TestAttemptAdoptControlServerIncompatibleCapabilityConnectionRefusedCountsAsSuccess(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "current-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + identity := validIdentity() + identity.Capabilities = []string{"some-other-capability"} + client := &countingShutdownControlClient{ + fakeAdoptionControlClient: fakeAdoptionControlClient{ + heldCredential: "current-token", + identity: identity, + rotateResult: &agentctl.CredentialRotationResult{RotationID: 7, Credential: "rotated-token"}, + shutdownErr: errors.New("dial tcp 127.0.0.1:9999: connect: connection refused"), + }, + } + store, _ := adoptionFixture(t, record, &client.fakeAdoptionControlClient) + factory := func(string) (AdoptionControlClient, error) { return client, nil } + store.liveStandaloneRecords = []*models.ExecutorRunning{{SessionID: "session-1"}} + + outcome := AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted || outcome.Reason != AdoptionReasonCapabilityIncompatible { + t.Fatalf("outcome = %+v, want unadopted capability_incompatible", outcome) + } + if client.shutdownAttempts != 1 { + t.Fatalf("shutdown attempts = %d, want exactly 1: a connection-refused error must not be retried", client.shutdownAttempts) + } + if len(store.repairedSessionIDs) != 1 || store.repairedSessionIDs[0] != "session-1" { + t.Fatalf("repaired sessions = %v, want session-1 repaired: an already-absent server counts as a successful stop", store.repairedSessionIDs) + } +} + +// TestAttemptAdoptControlServerSucceedsRotatesAndPersists pins the happy +// path: home matches, rotation succeeds, capability subset is satisfied, +// the new credential is durably stored under the SAME secret ID, the record +// is rewritten with the freshly observed identity fields, and confirm is +// sent last. +func TestAttemptAdoptControlServerSucceedsRotatesAndPersists(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "current-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + identity := validIdentity() + client := &fakeAdoptionControlClient{ + heldCredential: "current-token", + identity: identity, + rotateResult: &agentctl.CredentialRotationResult{RotationID: 3, Credential: "rotated-token"}, + } + store, factory := adoptionFixture(t, record, client) + + outcome := AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if !outcome.Adopted { + t.Fatalf("outcome = %+v, want Adopted", outcome) + } + if outcome.Endpoint != record.Endpoint { + t.Fatalf("Endpoint = %q, want %q", outcome.Endpoint, record.Endpoint) + } + if outcome.Credential != "rotated-token" { + t.Fatalf("Credential = %q, want rotated-token", outcome.Credential) + } + // Review round 3, finding 5: the adopted server's own reported unowned + // period must travel back on the outcome, not be left for the caller to + // recompute from its own (potentially stale) local config. + if outcome.UnownedPeriod != 300*time.Second { + t.Fatalf("UnownedPeriod = %v, want 300s (identity.UnownedPeriodMS read back verbatim)", outcome.UnownedPeriod) + } + if !client.confirmCalled || client.confirmedID != 3 { + t.Fatalf("confirm not called with rotation id 3: called=%v id=%d", client.confirmCalled, client.confirmedID) + } + if client.shutdownCalled { + t.Fatal("ShutdownControlServer was called on a successful adoption") + } + + got, err := secretStore.Reveal(context.Background(), secretID) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + if got != "rotated-token" { + t.Fatalf("stored credential = %q, want rotated-token", got) + } + + if len(store.upserts) != 1 { + t.Fatalf("upserts = %d, want 1", len(store.upserts)) + } + written := store.upserts[0] + if written.ServerIdentity != identity.ServerIdentity { + t.Fatalf("ServerIdentity = %q, want %q", written.ServerIdentity, identity.ServerIdentity) + } + if written.CredentialSecretID != secretID { + t.Fatalf("CredentialSecretID = %q, want unchanged %q (rotation reuses the same secret)", written.CredentialSecretID, secretID) + } + if written.Endpoint != record.Endpoint { + t.Fatalf("Endpoint = %q, want unchanged %q", written.Endpoint, record.Endpoint) + } + // The remaining two fields decide whether a LATER launch can adopt this + // server at all: capabilities gate compatibility, and CreatedAt must + // survive rather than being reset to now by every adoption, which would + // make the record look freshly created forever. + if len(written.Capabilities) != len(identity.Capabilities) { + t.Fatalf("Capabilities = %v, want the live server's %v", written.Capabilities, identity.Capabilities) + } + for i, capability := range identity.Capabilities { + if written.Capabilities[i] != capability { + t.Fatalf("Capabilities = %v, want the live server's %v", written.Capabilities, identity.Capabilities) + } + } + if !written.CreatedAt.Equal(record.CreatedAt) { + t.Fatalf("CreatedAt = %v, want the recorded %v carried through", written.CreatedAt, record.CreatedAt) + } + if written.DiagnosticLogPath != testDiagnosticLogPath { + t.Fatalf("DiagnosticLogPath = %q, want the live server's %q", written.DiagnosticLogPath, testDiagnosticLogPath) + } +} + +// TestAttemptAdoptControlServerServerIdentityMismatchRefusesWithoutStop pins +// AC-CONTROL-OWNERSHIP-001.2: a live server whose home directory matches but +// whose per-launch ServerIdentity does not match the previously recorded +// value is a different process sharing the same home directory, not the +// server this record describes, and must be refused exactly like a +// home-directory mismatch -- never stopped, since this backend cannot prove +// it is its own. +func TestAttemptAdoptControlServerServerIdentityMismatchRefusesWithoutStop(t *testing.T) { + record := validRecord() + record.ServerIdentity = "recorded-identity" + identity := validIdentity() + identity.ServerIdentity = "a-different-live-identity" + client := &fakeAdoptionControlClient{identity: identity} + store, factory := adoptionFixture(t, record, client) + + outcome := AttemptAdoptControlServer(context.Background(), store, newInMemorySecretStore(), factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted || outcome.Reason != AdoptionReasonIdentityMismatch { + t.Fatalf("outcome = %+v, want unadopted identity_mismatch", outcome) + } + if client.shutdownCalled { + t.Fatal("ShutdownControlServer was called for a ServerIdentity-mismatched server") + } +} + +// TestAttemptAdoptControlServerEmptyRecordedServerIdentityRefuses pins the +// fail-closed side of the identity proof end to end: a record that carries no +// server identity cannot prove which process it describes, and the home +// directory it shares with every control server on the installation is not a +// substitute. The server is left unadopted rather than claimed on an +// unprovable match. +func TestAttemptAdoptControlServerEmptyRecordedServerIdentityRefuses(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "current-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.ServerIdentity = "" + record.CredentialSecretID = secretID + identity := validIdentity() + identity.ServerIdentity = "whatever-this-server-reports" + client := &fakeAdoptionControlClient{ + heldCredential: "current-token", + identity: identity, + rotateResult: &agentctl.CredentialRotationResult{RotationID: 1, Credential: "rotated-token"}, + } + store, factory := adoptionFixture(t, record, client) + + outcome := AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted || outcome.Reason != AdoptionReasonIdentityMismatch { + t.Fatalf("outcome = %+v, want unadopted identity_mismatch for an unset recorded ServerIdentity", outcome) + } + if client.shutdownCalled { + t.Fatal("ShutdownControlServer was called for a server whose identity could not be proven") + } +} + +// TestAttemptAdoptControlServerUnownedPeriodFallsBackToFloorWhenUnreported +// pins the legacy/pre-upgrade shape: a server that answers /identity without +// an unowned_period_ms field (zero value) reports a zero UnownedPeriod on the +// outcome, rather than a value this package fabricates -- the caller +// (backendapp.resolveAdoptedRenewalPeriod) owns the floor fallback. +func TestAttemptAdoptControlServerUnownedPeriodFallsBackToFloorWhenUnreported(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "current-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + identity := validIdentity() + identity.UnownedPeriodMS = 0 + client := &fakeAdoptionControlClient{ + heldCredential: "current-token", + identity: identity, + rotateResult: &agentctl.CredentialRotationResult{RotationID: 1, Credential: "rotated-token"}, + } + store, factory := adoptionFixture(t, record, client) + + outcome := AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if !outcome.Adopted { + t.Fatalf("outcome = %+v, want Adopted", outcome) + } + if outcome.UnownedPeriod != 0 { + t.Fatalf("UnownedPeriod = %v, want 0 (unreported), so the caller applies its own floor", outcome.UnownedPeriod) + } +} + +// TestAttemptAdoptControlServerCredentialKeepsRotatingAcrossAdoptions pins a +// chain of two or more consecutive restarts -- this feature's own core +// scenario: each adoption's rotated credential is the one the NEXT adoption +// must present and receive a further rotation for, under the SAME secret +// slot throughout, since design 01 "Single driver" uses exactly one +// credential for both control-plane and per-instance operations and there +// is nothing else to keep synchronized across the chain. +func TestAttemptAdoptControlServerCredentialKeepsRotatingAcrossAdoptions(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "bootstrap-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + client := &fakeAdoptionControlClient{ + heldCredential: "bootstrap-token", + identity: validIdentity(), + rotateResult: &agentctl.CredentialRotationResult{RotationID: 1, Credential: "rotated-token-1"}, + } + store, factory := adoptionFixture(t, record, client) + + first := AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + if !first.Adopted { + t.Fatalf("first adoption = %+v, want Adopted", first) + } + if first.Credential != "rotated-token-1" { + t.Fatalf("first Credential = %q, want rotated-token-1", first.Credential) + } + if len(store.upserts) != 1 { + t.Fatalf("upserts = %d, want 1 after the first adoption", len(store.upserts)) + } + if store.upserts[0].CredentialSecretID != secretID { + t.Fatalf("CredentialSecretID = %q, want unchanged %q across the chain", store.upserts[0].CredentialSecretID, secretID) + } + + // Simulate the next restart reading back the record the first call wrote. + // The server now holds the credential the first adoption rotated it to, + // so that is what it can prove possession of. + store.record = store.upserts[0] + client.heldCredential = "rotated-token-1" + client.rotateResult = &agentctl.CredentialRotationResult{RotationID: 2, Credential: "rotated-token-2"} + + second := AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + if !second.Adopted { + t.Fatalf("second adoption = %+v, want Adopted", second) + } + if second.Credential != "rotated-token-2" { + t.Fatalf("second Credential = %q, want rotated-token-2", second.Credential) + } +} + +// TestAttemptAdoptControlServerConfirmFailureStillReportsAdopted pins the +// narrow edge this Build turn documents explicitly: both durable writes +// (secret + record) already completed by the time confirm is attempted, so +// a confirm-call failure does not undo local durable state and adoption is +// still reported successful. The credential and record already name the +// rotated value regardless of whether the server ever hears about it. +func TestAttemptAdoptControlServerConfirmFailureStillReportsAdopted(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "current-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + client := &fakeAdoptionControlClient{ + heldCredential: "current-token", + identity: validIdentity(), + rotateResult: &agentctl.CredentialRotationResult{RotationID: 9, Credential: "rotated-token"}, + confirmErr: errors.New("connection reset"), + } + store, factory := adoptionFixture(t, record, client) + + outcome := AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if !outcome.Adopted { + t.Fatalf("outcome = %+v, want Adopted despite confirm failure", outcome) + } +} + +// TestAttemptAdoptControlServerRotationStorageFailureIsIncomplete pins +// AC-002.5: when the durable secret write fails, adoption is incomplete, +// confirm must never be sent (it would tell the server to drop a credential +// this backend never durably recorded), and the record must not be rewritten. +func TestAttemptAdoptControlServerRotationStorageFailureIsIncomplete(t *testing.T) { + secretStore := &failingUpdateSecretStore{inMemorySecretStore: newInMemorySecretStore()} + secretID, err := storeControlServerCredential(context.Background(), secretStore.inMemorySecretStore, "", "current-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + secretStore.failUpdateFor = secretID + record := validRecord() + record.CredentialSecretID = secretID + client := &fakeAdoptionControlClient{ + heldCredential: "current-token", + identity: validIdentity(), + rotateResult: &agentctl.CredentialRotationResult{RotationID: 5, Credential: "rotated-token"}, + } + store, factory := adoptionFixture(t, record, client) + + outcome := AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted || outcome.Reason != AdoptionReasonCredentialRotationFailed { + t.Fatalf("outcome = %+v, want unadopted credential_rotation_failed", outcome) + } + if client.confirmCalled { + t.Fatal("ConfirmCredentialRotation was called despite the durable secret write failing") + } + if len(store.upserts) != 0 { + t.Fatal("control server record was rewritten despite the durable secret write failing") + } +} + +// TestRecordFreshControlServerReusesPriorSecretID pins the "own server +// started after a refused or failed adoption" failure-table row: the record +// is rewritten to name the new server, and the existing credential secret +// row is reused in place (updated to the new server's bootstrap token) +// rather than leaving the old row orphaned. +func TestRecordFreshControlServerReusesPriorSecretID(t *testing.T) { + secretStore := newInMemorySecretStore() + priorSecretID, err := storeControlServerCredential(context.Background(), secretStore, "", "old-servers-token") + if err != nil { + t.Fatalf("seed prior credential: %v", err) + } + store := &fakeAdoptionRecordStore{record: &models.ControlServerRecord{ + Endpoint: "127.0.0.1:8888", + CredentialSecretID: priorSecretID, + }} + client := &fakeAdoptionControlClient{heldCredential: "old-servers-token", identity: validIdentity()} + + err = RecordFreshControlServer(context.Background(), store, secretStore, client, "127.0.0.1:9001", "new-servers-token") + if err != nil { + t.Fatalf("RecordFreshControlServer: %v", err) + } + + if len(store.upserts) != 1 { + t.Fatalf("upserts = %d, want 1", len(store.upserts)) + } + written := store.upserts[0] + if written.Endpoint != "127.0.0.1:9001" { + t.Fatalf("Endpoint = %q, want the new server's endpoint", written.Endpoint) + } + if written.CredentialSecretID != priorSecretID { + t.Fatalf("CredentialSecretID = %q, want reused %q", written.CredentialSecretID, priorSecretID) + } + + got, err := secretStore.Reveal(context.Background(), priorSecretID) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + if got != "new-servers-token" { + t.Fatalf("stored credential = %q, want new-servers-token", got) + } +} + +// TestRecordFreshControlServerCreatesSecretWhenNoPriorRecord pins the +// first-ever-launch case: no prior record exists at all, so a brand new +// secret is created rather than a reuse being attempted. +func TestRecordFreshControlServerCreatesSecretWhenNoPriorRecord(t *testing.T) { + secretStore := newInMemorySecretStore() + store := &fakeAdoptionRecordStore{} + client := &fakeAdoptionControlClient{identity: validIdentity()} + + err := RecordFreshControlServer(context.Background(), store, secretStore, client, "127.0.0.1:9001", "fresh-token") + if err != nil { + t.Fatalf("RecordFreshControlServer: %v", err) + } + + if len(store.upserts) != 1 { + t.Fatalf("upserts = %d, want 1", len(store.upserts)) + } + written := store.upserts[0] + if written.CredentialSecretID == "" { + t.Fatalf("written = %+v, want a populated credential secret ID", written) + } + got, err := secretStore.Reveal(context.Background(), written.CredentialSecretID) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + if got != "fresh-token" { + t.Fatalf("stored credential = %q, want fresh-token", got) + } +} + +// TestRecordFreshControlServerPropagatesIdentityFailure pins that a failure +// to read the freshly spawned server's own identity aborts before any +// durable write, rather than writing a record with blank fields. +func TestRecordFreshControlServerPropagatesIdentityFailure(t *testing.T) { + secretStore := newInMemorySecretStore() + store := &fakeAdoptionRecordStore{} + client := &fakeAdoptionControlClient{identityErr: errors.New("connection refused")} + + err := RecordFreshControlServer(context.Background(), store, secretStore, client, "127.0.0.1:9001", "fresh-token") + if err == nil { + t.Fatal("want error, got nil") + } + if len(store.upserts) != 0 { + t.Fatal("record was written despite the identity read failing") + } +} + +// --- ReclaimUnneededControlServer (Review round 1, finding 1: AC-EXECUTORS-SURVIVAL-005.5) --- + +// TestReclaimUnneededControlServerNoRecordDoesNothing pins that a disabled +// launch with no recorded control endpoint contacts nothing. +// TestReclaimUnneededControlServerNothingAnswersDoesNothing pins that an +// endpoint that answers nothing is left alone -- nothing to reclaim. The +// credential store is seeded exactly as +// TestReclaimUnneededControlServerStopsOwnServerAndRepairsRecords is, so a +// real credential is available: if the GetIdentity-error check under test +// were ever removed, the function would proceed to a genuine shutdown +// attempt and this assertion would actually catch it, rather than passing +// only because credential-unavailable happened to short-circuit first. +// TestReclaimUnneededControlServerIdentityMismatchLeavesServerUntouched pins +// that a server belonging to another installation is never stopped -- the +// same ownership gate adoption applies, on the path that runs with the +// capability off. The credential store is seeded exactly as +// TestReclaimUnneededControlServerStopsOwnServerAndRepairsRecords is, so a +// real credential is available: if the ownership check under test were ever +// removed, the function would proceed to a genuine shutdown attempt and this +// assertion would actually catch it, rather than passing only because +// credential-unavailable happened to short-circuit first. +// TestReclaimUnneededControlServerServerIdentityMismatchLeavesServerUntouched +// pins the same ServerIdentity gate AttemptAdoptControlServer applies +// (Review round 3, finding 2) for the reclaim path too: a home-directory +// match alone is not enough to prove this is the exact recorded server. +// TestReclaimUnneededControlServerCredentialUnavailableLeavesServerUntouched +// pins AC-EXECUTORS-SURVIVAL-005.5's refusal-before-any-stop-attempt when +// the stored credential cannot be read. +// TestReclaimUnneededControlServerStopsOwnServerAndRepairsRecords pins the +// success path: a proven-own server is stopped via the ownership-shutdown +// operation without ever rotating the credential (adoption is forbidden +// while the capability is disabled, AC-EXECUTORS-SURVIVAL-005.1), and every +// live standalone recovery-inventory record is repaired immediately. +// TestReclaimUnneededControlServerRetriesStopAndSkipsRepairOnFailure pins +// the shared AC-EXECUTORS-CONTROL-OWNERSHIP-004.7 retry contract applied to +// this stop too: bounded retries, and no repair when they're exhausted. The +// injected failure is a genuinely retryable server error, not a +// connection-refused/dial failure -- see +// TestReclaimUnneededControlServerConnectionRefusedCountsAsSuccess for that +// carve-out. +// TestReclaimUnneededControlServerConnectionRefusedCountsAsSuccess pins +// AC-EXECUTORS-CONTROL-OWNERSHIP-004.7's "already absent counts as success" +// carve-out for this stop too: a dial-level connection-refused error must +// not be retried to exhaustion, and the immediate-repair step still runs. diff --git a/apps/backend/internal/agent/runtime/lifecycle/control_server_credential.go b/apps/backend/internal/agent/runtime/lifecycle/control_server_credential.go new file mode 100644 index 0000000000..581e6aad08 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/control_server_credential.go @@ -0,0 +1,128 @@ +package lifecycle + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/secrets" +) + +// controlServerCredentialSecretName is the fixed secret name for the +// installation's single standalone control-server ownership credential. It +// is never looked up by name -- only by the ID recorded in +// models.ControlServerRecord.CredentialSecretID -- so a fixed, non-unique +// name is safe. +const controlServerCredentialSecretName = "agent-survival-control-server-credential" + +const controlServerCredentialSecretIDPrefix = "kandev-runtime:control-server:" + +// storeControlServerCredential durably stores the control server's ownership +// credential and returns the secret ID that +// models.ControlServerRecord.CredentialSecretID should reference. Storing +// the token itself in the record would put a bearer credential for a +// service that executes commands in a user's worktree into the database in +// clear (design 01, "Ownership identity and credential") -- the record +// holds only this reference. +// +// existingSecretID, when non-empty, updates that secret in place so an +// ordinary credential rotation reuses the same reference and the record's +// CredentialSecretID never has to change. When the referenced secret is +// absent (e.g. deleted out of band), this falls back to creating a new one +// rather than failing: refusing to store a rotated credential would leave +// the backend unable to ever adopt this server again. When the referenced +// secret is present but its current value cannot be read (a transient +// failure, not absence), this also falls back to creating a new one rather +// than overwriting it: see tryReuseCredentialSecret. +func storeControlServerCredential(ctx context.Context, store secrets.SecretStore, existingSecretID, token string) (string, error) { + if store == nil { + return "", errors.New("secret store is unavailable") + } + if token == "" { + return "", errors.New("control server credential is required") + } + + // Older installations used a generated, user-visible secret ID. Do not + // update that row during rotation because it would expose the control-server + // bearer credential through the user secret APIs. New and migrated rows use + // the internal runtime prefix and remain hidden by UserVisibleStore. + if isControlServerCredentialSecretID(existingSecretID) { + reused, err := tryReuseCredentialSecret(ctx, store, existingSecretID, token) + if err != nil { + return "", err + } + if reused { + return existingSecretID, nil + } + } + + secret := &secrets.SecretWithValue{ + Secret: secrets.Secret{ + ID: controlServerCredentialSecretIDPrefix + uuid.NewString(), + Name: controlServerCredentialSecretName, + }, + Value: token, + } + if err := store.Create(ctx, secret); err != nil { + return "", err + } + return secret.ID, nil +} + +func isControlServerCredentialSecretID(id string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(id)), controlServerCredentialSecretIDPrefix) +} + +// tryReuseCredentialSecret overwrites existingSecretID in place with token +// and reports whether it did. +// +// It first reveals the secret's current value to prove the row is genuinely +// present and readable before overwriting it: Update alone cannot tell +// "absent" apart from "present but transiently unreadable" -- it blindly +// overwrites either way, since it never re-decrypts the prior value. +// Overwriting a slot whose value cannot currently be read would destroy +// whatever credential is there over what may be only a transient failure, +// which AC-EXECUTORS-CONTROL-OWNERSHIP-001.9 forbids ("shall not delete the +// stored credential" on a transient read error, "because the failure is on +// this side and destroying a secret over a transient read error is +// unrecoverable"). +// +// A genuinely absent secret, or one that fails to reveal for any other +// reason, both report (false, nil) rather than an error: the caller falls +// back to creating a fresh secret instead of failing the whole operation, so +// a transient blip reading the OLD credential's row never blocks durably +// recording a new one (leaving one orphaned row behind is an acceptable cost +// next to destroying a credential that might still be needed). Only a +// failure of the Update call itself -- the row having just been proven +// present and readable, then failing to write -- is surfaced as an error. +func tryReuseCredentialSecret(ctx context.Context, store secrets.SecretStore, existingSecretID, token string) (bool, error) { + if _, err := revealGlobalSecret(ctx, store, existingSecretID); err != nil { + return false, nil + } + if err := store.Update(ctx, existingSecretID, &secrets.UpdateSecretRequest{Value: &token}); err != nil { + if errors.Is(err, secrets.ErrNotFound) { + return false, nil + } + return false, err + } + return true, nil +} + +// revealControlServerCredential retrieves the decrypted ownership credential +// referenced by a control-server record. An empty reference is rejected +// before any store contact, distinct from a not-found lookup, so a caller +// can tell "record has no credential" apart from "stored reference is +// stale" -- design 02's failure table treats these differently +// (credential-unavailable vs authentication failure). +func revealControlServerCredential(ctx context.Context, store secrets.SecretStore, secretID string) (string, error) { + if secretID == "" { + return "", errors.New("control server credential reference is empty") + } + value, err := revealGlobalSecret(ctx, store, secretID) + if err != nil { + return "", fmt.Errorf("reveal control server credential: %w", err) + } + return value, nil +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/control_server_credential_test.go b/apps/backend/internal/agent/runtime/lifecycle/control_server_credential_test.go new file mode 100644 index 0000000000..ed5a1cf725 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/control_server_credential_test.go @@ -0,0 +1,188 @@ +package lifecycle + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/kandev/kandev/internal/secrets" +) + +// TestStoreControlServerCredentialCreatesWhenNoExistingSecretID pins the +// bootstrap path: the control-server record starts with no +// CredentialSecretID, so storing the token received from the bootstrap +// handshake must create a new secret and return its ID for the caller to +// persist onto the record. +func TestStoreControlServerCredentialCreatesWhenNoExistingSecretID(t *testing.T) { + store := newInMemorySecretStore() + + secretID, err := storeControlServerCredential(context.Background(), store, "", "the-bearer-token") + if err != nil { + t.Fatalf("storeControlServerCredential: %v", err) + } + if secretID == "" { + t.Fatal("secretID is empty, want a generated ID") + } + if !strings.HasPrefix(secretID, controlServerCredentialSecretIDPrefix) { + t.Fatalf("secretID = %q, want an internal runtime secret ID", secretID) + } + + got, err := store.Reveal(context.Background(), secretID) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + if got != "the-bearer-token" { + t.Fatalf("revealed value = %q, want %q", got, "the-bearer-token") + } +} + +// TestStoreControlServerCredentialUpdatesInPlaceWhenSecretIDGiven pins +// rotation: a credential rotation replaces the token value under the SAME +// secret ID, so ControlServerRecord.CredentialSecretID never has to change +// on an ordinary rotation. +func TestStoreControlServerCredentialUpdatesInPlaceWhenSecretIDGiven(t *testing.T) { + store := newInMemorySecretStore() + existingID, err := storeControlServerCredential(context.Background(), store, "", "original-token") + if err != nil { + t.Fatalf("storeControlServerCredential(initial): %v", err) + } + + gotID, err := storeControlServerCredential(context.Background(), store, existingID, "rotated-token") + if err != nil { + t.Fatalf("storeControlServerCredential(rotation): %v", err) + } + if gotID != existingID { + t.Fatalf("gotID = %q, want unchanged %q", gotID, existingID) + } + + got, err := store.Reveal(context.Background(), existingID) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + if got != "rotated-token" { + t.Fatalf("revealed value = %q, want %q", got, "rotated-token") + } +} + +// TestStoreControlServerCredentialFallsBackToCreateWhenSecretIDMissing pins +// the defensive case: the record names a secret ID the store no longer has +// (e.g. deleted out of band). Rotation must still durably store the new +// credential rather than fail, because the alternative -- refusing rotation +// -- would leave the backend unable to ever adopt this server again. +func TestStoreControlServerCredentialFallsBackToCreateWhenSecretIDMissing(t *testing.T) { + store := newInMemorySecretStore() + + secretID, err := storeControlServerCredential(context.Background(), store, "does-not-exist", "fresh-token") + if err != nil { + t.Fatalf("storeControlServerCredential: %v", err) + } + if secretID == "" || secretID == "does-not-exist" { + t.Fatalf("secretID = %q, want a freshly generated ID", secretID) + } + + got, err := store.Reveal(context.Background(), secretID) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + if got != "fresh-token" { + t.Fatalf("revealed value = %q, want %q", got, "fresh-token") + } +} + +// TestStoreControlServerCredentialDoesNotOverwriteUnreadableExistingSecret +// pins AC-EXECUTORS-CONTROL-OWNERSHIP-001.9's "shall not delete the stored +// credential" for a transient read error: when the existing secret is +// present but a Reveal on it currently fails (not absent -- a real row a +// later attempt might still recover), storing a new credential must not +// blindly overwrite that row via Update, since Update never re-decrypts the +// prior value and would silently destroy it. It must fall back to a fresh +// secret instead, and the old row's value must remain exactly as it was. +func TestStoreControlServerCredentialDoesNotOverwriteUnreadableExistingSecret(t *testing.T) { + store := newInMemorySecretStore() + existingID, err := storeControlServerCredential(context.Background(), store, "", "still-needed-token") + if err != nil { + t.Fatalf("storeControlServerCredential(initial): %v", err) + } + + store.revealErr = errors.New("transient read failure") + + gotID, err := storeControlServerCredential(context.Background(), store, existingID, "unrelated-fresh-token") + if err != nil { + t.Fatalf("storeControlServerCredential: %v", err) + } + if gotID == existingID { + t.Fatalf("gotID = %q, want a freshly allocated ID distinct from the unreadable existing one", gotID) + } + + store.revealErr = nil + got, err := store.Reveal(context.Background(), existingID) + if err != nil { + t.Fatalf("Reveal(existingID) after fallback: %v", err) + } + if got != "still-needed-token" { + t.Fatalf("existing secret value = %q, want untouched %q", got, "still-needed-token") + } + + gotFresh, err := store.Reveal(context.Background(), gotID) + if err != nil { + t.Fatalf("Reveal(gotID): %v", err) + } + if gotFresh != "unrelated-fresh-token" { + t.Fatalf("fresh secret value = %q, want %q", gotFresh, "unrelated-fresh-token") + } +} + +// TestStoreControlServerCredentialRejectsEmptyToken pins that an empty +// bearer token is never durably stored -- storing a blank credential would +// silently make adoption impossible to distinguish from "not yet bootstrapped". +func TestStoreControlServerCredentialRejectsEmptyToken(t *testing.T) { + store := newInMemorySecretStore() + + if _, err := storeControlServerCredential(context.Background(), store, "", ""); err == nil { + t.Fatal("want error for empty token, got nil") + } +} + +// TestRevealControlServerCredentialDelegatesToStore pins the read side: the +// helper returns the decrypted token for a known reference. +func TestRevealControlServerCredentialDelegatesToStore(t *testing.T) { + store := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), store, "", "the-bearer-token") + if err != nil { + t.Fatalf("storeControlServerCredential: %v", err) + } + + got, err := revealControlServerCredential(context.Background(), store, secretID) + if err != nil { + t.Fatalf("revealControlServerCredential: %v", err) + } + if got != "the-bearer-token" { + t.Fatalf("got = %q, want %q", got, "the-bearer-token") + } +} + +// TestRevealControlServerCredentialRejectsEmptyReference pins the +// credential-unavailable case distinct from authentication failure (design +// 02's failure table): a control-server record with no credential reference +// at all must fail before ever contacting the secret store. +func TestRevealControlServerCredentialRejectsEmptyReference(t *testing.T) { + store := newInMemorySecretStore() + + if _, err := revealControlServerCredential(context.Background(), store, ""); err == nil { + t.Fatal("want error for empty secret reference, got nil") + } +} + +// TestRevealControlServerCredentialPropagatesNotFound pins that a stale +// reference (secret deleted out of band) surfaces as secrets.ErrNotFound so +// the caller can map it to the distinct credential-unavailable adoption +// refusal reason rather than a generic failure. +func TestRevealControlServerCredentialPropagatesNotFound(t *testing.T) { + store := newInMemorySecretStore() + + _, err := revealControlServerCredential(context.Background(), store, "does-not-exist") + if !errors.Is(err, secrets.ErrNotFound) { + t.Fatalf("err = %v, want wrapped %v", err, secrets.ErrNotFound) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/control_server_ownership_proof.go b/apps/backend/internal/agent/runtime/lifecycle/control_server_ownership_proof.go new file mode 100644 index 0000000000..6cae8c5a72 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/control_server_ownership_proof.go @@ -0,0 +1,75 @@ +package lifecycle + +import ( + "context" + "crypto/rand" + "encoding/hex" + "time" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/common/ownershipproof" +) + +// newAdoptionChallenge returns a fresh challenge for one adoption attempt. +// A proof is only ever accepted for the challenge generated for that +// attempt, so this must never be derived from anything an observer could +// predict or replay. +func newAdoptionChallenge() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// controlServerHoldsCredential reports whether the process answering the +// recorded endpoint can demonstrate it already holds credential. Until it +// does, this backend has established only that something answers and echoes +// the values the identity endpoint hands out unauthenticated; sending the +// credential to it, or storing a replacement it invents, would hand an +// installation-wide secret to whatever took the port. +// +// A failure to answer is not a proof: the caller refuses adoption. +func controlServerHoldsCredential( + ctx context.Context, + client AdoptionControlClient, + credential string, + binding string, + recoveryReadTimeout time.Duration, + recoveryReadRetries int, +) bool { + challenge, err := newAdoptionChallenge() + if err != nil { + return false + } + + proofs, err := withAdoptionRetry(ctx, recoveryReadTimeout, recoveryReadRetries, + func(ctx context.Context) ([]string, error) { + return client.ProveOwnership(ctx, challenge) + }) + if err != nil { + return false + } + + return ownershipproof.Matches(credential, challenge, binding, proofs) +} + +// recordedDiagnosticLogPath reads the adopted server's diagnostic log +// location, which AC-EXECUTORS-CONTROL-OWNERSHIP-001.1 requires the record +// to name. It is retrieved after authentication because the endpoint that +// decides whether to authenticate discloses no filesystem path. +func recordedDiagnosticLogPath( + ctx context.Context, + client AdoptionControlClient, + recoveryReadTimeout time.Duration, + recoveryReadRetries int, +) string { + details, err := withAdoptionRetry(ctx, recoveryReadTimeout, recoveryReadRetries, + func(ctx context.Context) (*agentctl.ServerDetails, error) { + return client.GetServerDetails(ctx) + }) + if err != nil || details == nil { + return "" + } + return details.DiagnosticLogPath +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/control_server_ownership_proof_test.go b/apps/backend/internal/agent/runtime/lifecycle/control_server_ownership_proof_test.go new file mode 100644 index 0000000000..71a0085b24 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/control_server_ownership_proof_test.go @@ -0,0 +1,163 @@ +package lifecycle + +import ( + "context" + "errors" + "testing" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" +) + +// seedProofFixture builds an adoption fixture whose durable record names a +// credential the secret store really holds, so every refusal these tests +// observe is the ownership gate refusing and not an earlier gate short- +// circuiting on a missing record, identity, or secret. +func seedProofFixture(t *testing.T, client *fakeAdoptionControlClient) (*fakeAdoptionRecordStore, AdoptionControlClientFactory, *inMemorySecretStore) { + t.Helper() + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "backend-credential") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + store, factory := adoptionFixture(t, record, client) + return store, factory, secretStore +} + +// TestAdoptionNeverSendsTheCredentialToAServerThatCannotProvePossession is +// the reason this gate exists. Everything a control server needs in order to +// be adopted up to this point -- the endpoint, the advertised identity, the +// capability set -- is either public or attacker-chosen, so a process that +// binds the recorded port and echoes the record's values satisfies all of +// it. Only possession of the credential distinguishes the real server, and +// the credential itself must never be the thing that demonstrates it: if a +// backend sends it first and reads the response, it has already handed a +// long-lived secret to whatever answered. +func TestAdoptionNeverSendsTheCredentialToAServerThatCannotProvePossession(t *testing.T) { + client := &fakeAdoptionControlClient{ + identity: validIdentity(), + heldCredential: "some-other-credential", + rotateResult: &agentctl.CredentialRotationResult{RotationID: 1, Credential: "rotated"}, + } + store, factory, secretStore := seedProofFixture(t, client) + + outcome := AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted { + t.Fatalf("outcome = %+v, want unadopted", outcome) + } + if outcome.Reason != AdoptionReasonIdentityMismatch { + t.Fatalf("Reason = %q, want %q", outcome.Reason, AdoptionReasonIdentityMismatch) + } + if len(client.presentedTokens) != 0 { + t.Fatalf("credential was transmitted to an unproven server: %v", client.presentedTokens) + } + if client.shutdownCalled { + t.Fatal("ShutdownControlServer was called on a server this backend cannot prove is its own") + } + if len(store.upserts) != 0 { + t.Fatalf("the record was rewritten for an unproven server: %+v", store.upserts) + } +} + +// TestReclaimNeverSendsTheCredentialToAServerThatCannotProvePossession +// covers the same gate on the path that runs with the capability OFF. That +// path reaches a foreign server through the identical sequence and then +// stops it, so leaving it ungated would keep the vulnerability reachable in +// every shipped profile. +func TestReclaimNeverSendsTheCredentialToAServerThatCannotProvePossession(t *testing.T) { + client := &fakeAdoptionControlClient{ + identity: validIdentity(), + heldCredential: "some-other-credential", + } + store, factory, secretStore := seedProofFixture(t, client) + + ReclaimUnneededControlServer(context.Background(), store, secretStore, factory, + testHomeDir, 0, -1, newAdoptionTestLogger(t)) + + if len(client.presentedTokens) != 0 { + t.Fatalf("credential was transmitted to an unproven server: %v", client.presentedTokens) + } + if client.shutdownCalled { + t.Fatal("ShutdownControlServer was called on a server this backend cannot prove is its own") + } +} + +// TestAdoptionRefusesAServerThatWillNotAnswerTheChallenge pins the fail- +// closed direction: a server that errors on the proof request, or answers +// with nothing, has not proved possession, so it is refused exactly like one +// that answers wrongly. +func TestAdoptionRefusesAServerThatWillNotAnswerTheChallenge(t *testing.T) { + client := &fakeAdoptionControlClient{ + identity: validIdentity(), + heldCredential: "backend-credential", + proveErr: errors.New("404 page not found"), + } + store, factory, secretStore := seedProofFixture(t, client) + + outcome := AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if outcome.Adopted { + t.Fatalf("outcome = %+v, want unadopted", outcome) + } + if len(client.presentedTokens) != 0 { + t.Fatalf("credential was transmitted despite no proof: %v", client.presentedTokens) + } +} + +// TestEachAdoptionAttemptIssuesAFreshChallenge pins that the challenge is +// per-attempt. A fixed challenge would let an attacker who once observed a +// valid answer replay it forever without ever holding the credential, which +// is the entire property the proof buys. +func TestEachAdoptionAttemptIssuesAFreshChallenge(t *testing.T) { + client := &fakeAdoptionControlClient{ + identity: validIdentity(), + heldCredential: "backend-credential", + rotateResult: &agentctl.CredentialRotationResult{RotationID: 1, Credential: "backend-credential"}, + } + store, factory, secretStore := seedProofFixture(t, client) + + for range 3 { + AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + } + + if len(client.challenges) != 3 { + t.Fatalf("challenges issued = %d, want 3 (one per attempt)", len(client.challenges)) + } + seen := make(map[string]bool, len(client.challenges)) + for _, challenge := range client.challenges { + if challenge == "" { + t.Fatal("an empty challenge was issued, which proves nothing") + } + if seen[challenge] { + t.Fatalf("challenge %q was reused across attempts", challenge) + } + seen[challenge] = true + } +} + +// TestAdoptionAcceptsAServerThatProvesPossession is the open side: the same +// gate that refuses everything above must let the real server through, or it +// would be indistinguishable from disabling adoption outright. +func TestAdoptionAcceptsAServerThatProvesPossession(t *testing.T) { + client := &fakeAdoptionControlClient{ + identity: validIdentity(), + heldCredential: "backend-credential", + rotateResult: &agentctl.CredentialRotationResult{RotationID: 4, Credential: "rotated-credential"}, + } + store, factory, secretStore := seedProofFixture(t, client) + + outcome := AttemptAdoptControlServer(context.Background(), store, secretStore, factory, + testHomeDir, RequiredSurvivalCapabilities, 0, -1, newAdoptionTestLogger(t)) + + if !outcome.Adopted { + t.Fatalf("outcome = %+v, want adopted", outcome) + } + if len(client.presentedTokens) == 0 || client.presentedTokens[0] != "backend-credential" { + t.Fatalf("presented tokens = %v, want the recorded credential first", client.presentedTokens) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/control_server_reclaim_test.go b/apps/backend/internal/agent/runtime/lifecycle/control_server_reclaim_test.go new file mode 100644 index 0000000000..7dc57b193b --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/control_server_reclaim_test.go @@ -0,0 +1,187 @@ +package lifecycle + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestReclaimUnneededControlServerNoRecordDoesNothing(t *testing.T) { + store := &fakeAdoptionRecordStore{} + factory := func(string) (AdoptionControlClient, error) { + t.Fatal("client factory must not be called with no record") + return nil, nil + } + + ReclaimUnneededControlServer(context.Background(), store, newInMemorySecretStore(), factory, + testHomeDir, 0, -1, newAdoptionTestLogger(t)) +} + +func TestReclaimUnneededControlServerNothingAnswersDoesNothing(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "own-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + client := &fakeAdoptionControlClient{heldCredential: "own-token", identityErr: errors.New("connection refused")} + store, factory := adoptionFixture(t, record, client) + + ReclaimUnneededControlServer(context.Background(), store, secretStore, factory, + testHomeDir, 0, -1, newAdoptionTestLogger(t)) + + if client.shutdownCalled { + t.Fatal("ShutdownControlServer was called on a server that answered no identity") + } +} + +func TestReclaimUnneededControlServerIdentityMismatchLeavesServerUntouched(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "own-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + client := &fakeAdoptionControlClient{ + identity: validIdentity(), + heldCredential: "own-token", + boundHome: "/home/someone-else", + } + store, factory := adoptionFixture(t, record, client) + + ReclaimUnneededControlServer(context.Background(), store, secretStore, factory, + testHomeDir, 0, -1, newAdoptionTestLogger(t)) + + if client.shutdownCalled { + t.Fatal("ShutdownControlServer was called on a server belonging to another installation") + } + if len(client.presentedTokens) != 0 { + t.Fatalf("credential was sent to a server that could not prove possession: %v", client.presentedTokens) + } +} + +func TestReclaimUnneededControlServerServerIdentityMismatchLeavesServerUntouched(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "own-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + record.ServerIdentity = "recorded-identity" + identity := validIdentity() + identity.ServerIdentity = "a-different-live-identity" + client := &fakeAdoptionControlClient{heldCredential: "own-token", identity: identity} + store, factory := adoptionFixture(t, record, client) + + ReclaimUnneededControlServer(context.Background(), store, secretStore, factory, + testHomeDir, 0, -1, newAdoptionTestLogger(t)) + + if client.shutdownCalled { + t.Fatal("ShutdownControlServer was called on a ServerIdentity-mismatched server") + } +} + +func TestReclaimUnneededControlServerCredentialUnavailableLeavesServerUntouched(t *testing.T) { + record := validRecord() + record.CredentialSecretID = "does-not-exist" + client := &fakeAdoptionControlClient{identity: validIdentity()} + store, factory := adoptionFixture(t, record, client) + + ReclaimUnneededControlServer(context.Background(), store, newInMemorySecretStore(), factory, + testHomeDir, 0, -1, newAdoptionTestLogger(t)) + + if client.shutdownCalled { + t.Fatal("ShutdownControlServer was called despite the credential being unavailable") + } +} + +func TestReclaimUnneededControlServerStopsOwnServerAndRepairsRecords(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "own-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + client := &fakeAdoptionControlClient{heldCredential: "own-token", identity: validIdentity()} + store, factory := adoptionFixture(t, record, client) + store.liveStandaloneRecords = []*models.ExecutorRunning{{SessionID: "session-1"}} + + ReclaimUnneededControlServer(context.Background(), store, secretStore, factory, + testHomeDir, 0, -1, newAdoptionTestLogger(t)) + + if !client.shutdownCalled { + t.Fatal("ShutdownControlServer was not called for a proven-own detached server") + } + if client.rotateResult != nil || len(client.presentedTokens) == 0 || client.presentedTokens[0] != "own-token" { + t.Fatalf("presented tokens = %v, want the stored own-token used directly, never rotated", client.presentedTokens) + } + if len(store.repairedSessionIDs) != 1 || store.repairedSessionIDs[0] != "session-1" { + t.Fatalf("repaired sessions = %v, want session-1 repaired immediately after the successful stop", store.repairedSessionIDs) + } +} + +func TestReclaimUnneededControlServerRetriesStopAndSkipsRepairOnFailure(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "own-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + client := &countingShutdownControlClient{ + fakeAdoptionControlClient: fakeAdoptionControlClient{ + heldCredential: "own-token", + identity: validIdentity(), + shutdownErr: errors.New("500 internal server error"), + }, + } + store, _ := adoptionFixture(t, record, &client.fakeAdoptionControlClient) + factory := func(string) (AdoptionControlClient, error) { return client, nil } + store.liveStandaloneRecords = []*models.ExecutorRunning{{SessionID: "session-1"}} + + ReclaimUnneededControlServer(context.Background(), store, secretStore, factory, + testHomeDir, 0, -1, newAdoptionTestLogger(t)) + + if client.shutdownAttempts < 2 { + t.Fatalf("shutdown attempts = %d, want more than one (bounded retry)", client.shutdownAttempts) + } + if len(store.repairedSessionIDs) != 0 { + t.Fatalf("repaired sessions = %v, want none when every stop retry fails", store.repairedSessionIDs) + } +} + +func TestReclaimUnneededControlServerConnectionRefusedCountsAsSuccess(t *testing.T) { + secretStore := newInMemorySecretStore() + secretID, err := storeControlServerCredential(context.Background(), secretStore, "", "own-token") + if err != nil { + t.Fatalf("seed credential: %v", err) + } + record := validRecord() + record.CredentialSecretID = secretID + client := &countingShutdownControlClient{ + fakeAdoptionControlClient: fakeAdoptionControlClient{ + heldCredential: "own-token", + identity: validIdentity(), + shutdownErr: errors.New("dial tcp 127.0.0.1:9999: connect: connection refused"), + }, + } + store, _ := adoptionFixture(t, record, &client.fakeAdoptionControlClient) + factory := func(string) (AdoptionControlClient, error) { return client, nil } + store.liveStandaloneRecords = []*models.ExecutorRunning{{SessionID: "session-1"}} + + ReclaimUnneededControlServer(context.Background(), store, secretStore, factory, + testHomeDir, 0, -1, newAdoptionTestLogger(t)) + + if client.shutdownAttempts != 1 { + t.Fatalf("shutdown attempts = %d, want exactly 1: a connection-refused error must not be retried", client.shutdownAttempts) + } + if len(store.repairedSessionIDs) != 1 || store.repairedSessionIDs[0] != "session-1" { + t.Fatalf("repaired sessions = %v, want session-1 repaired: an already-absent server counts as a successful stop", store.repairedSessionIDs) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/execution_store.go b/apps/backend/internal/agent/runtime/lifecycle/execution_store.go index d0288fc694..da61b5e152 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/execution_store.go +++ b/apps/backend/internal/agent/runtime/lifecycle/execution_store.go @@ -323,6 +323,10 @@ func (s *ExecutionStore) BeginPrompt(executionID string) (uint64, error) { } func beginExecutionPrompt(execution *AgentExecution) uint64 { + // A prompt is about to be dispatched through this object, so a + // recovered-but-not-yet-adopted generation must not later clobber it with + // a stale pre-restart completion (see recoveredPromptGenerationPending). + execution.recoveredPromptGenerationPending.Store(false) execution.promptGeneration++ execution.promptCompletionGeneration = 0 // The new generation is not dispatched until its triggerPrompt succeeds; a diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go index 0345ced837..8f7b096ff5 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go @@ -147,8 +147,11 @@ type ExecutorBackend interface { StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error // RecoverInstances discovers and recovers instances that were running before a restart. + // records is the live standalone recovery-inventory read at startup step 3 + // (Manager.ListLiveStandaloneExecutorsRunning); only the standalone runtime + // acts on it today, every other implementation ignores it. // Returns recovered instances that can be re-tracked by the manager. - RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) + RecoverInstances(ctx context.Context, records []*models.ExecutorRunning) ([]*ExecutorInstance, error) // GetInteractiveRunner returns the interactive runner for passthrough mode. // May return nil if the runtime doesn't support passthrough mode. @@ -256,7 +259,12 @@ const ( MetadataKeyModelOverride = "model_override" // Office metadata keys - MetadataKeySkillManifestJSON = "skill_manifest_json" + MetadataKeySkillManifestJSON = "skill_manifest_json" + // MetadataKeyOfficeAgentProfileID persists AgentExecution.OfficeAgentProfileID + // (AC-EXECUTORS-SURVIVAL-002.14's "Office profile identity" reconstruction + // row): a new key in the existing metadata column rather than a schema + // change. Empty for every non-Office launch, which is legitimate, not + // missing. MetadataKeyOfficeAgentProfileID = "office_agent_profile_id" // SSH runtime metadata keys (per-session, except SSHWorkdirRoot which is per-profile). @@ -661,6 +669,34 @@ type ExecutorInstance struct { StopReason string AgentStopFailed bool + // Env is the adopted instance's own runtime environment, read back rather + // than pushed (AC-EXECUTORS-SURVIVAL-002.14's "runtime environment" row: + // deliberately memory-only, so it is only ever populated by a recovery + // path -- StandaloneExecutor.RecoverInstances -- reading it back from the + // live instance; a fresh launch's environment flows through + // ExecutorCreateRequest.Env instead, never through this field. + Env map[string]string + + // WorkspaceSourceRoots is the adopted instance's own live source-root + // allowlist, read back rather than pushed (AC-EXECUTORS-SURVIVAL-002.14's + // "workspace source roots" row). Same recovery-only shape as Env above. + WorkspaceSourceRoots []string + + // ProviderSessionID is the adopted instance's own live agent-CLI session + // identity, read back rather than pushed (AC-EXECUTORS-SURVIVAL-002.14's + // "provider session identity" row). Same recovery-only shape as Env above. + ProviderSessionID string + + // AgentProfileID is a recovery-only carrier for + // AC-EXECUTORS-SURVIVAL-002.14's "agent profile identity" row: the + // recovery-inventory record's execution-profile column, read by + // StandaloneExecutor.buildRecoveredInstances so the recovery loop can set + // AgentExecution.AgentProfileID before re-deriving agent identity, without + // waiting on persistExecutorRunningResult's separate DB-backed self-heal. + // A fresh launch's profile flows through ExecutorCreateRequest.AgentProfileID + // instead, never through this field. + AgentProfileID string + // AuthToken is the agentctl auth token retrieved via handshake. // Populated by authenticated container/remote executors for encrypted storage in SecretStore. // Empty for standalone (launcher-owned token wired via cfg.Agent.StandaloneAuthToken) diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go index 5a2cf44b4c..9bdd40d8e9 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go @@ -771,7 +771,7 @@ func dockerCleanupContext(ctx context.Context, agentStopFailed bool) (context.Co return ctx, func() {} } -func (r *DockerExecutor) RecoverInstances(_ context.Context) ([]*ExecutorInstance, error) { +func (r *DockerExecutor) RecoverInstances(_ context.Context, _ []*models.ExecutorRunning) ([]*ExecutorInstance, error) { // No-op: Docker client is initialized lazily on first use. // If no session has used Docker yet, there's nothing to recover. // Running containers from a previous backend process will be detected diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go index 7cbe8fa270..7fa1a916a7 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go @@ -109,7 +109,7 @@ func TestDockerExecutor_RecoverInstances(t *testing.T) { log := newTestDockerLogger() exec := NewDockerExecutor(config.DockerConfig{}, "", log) - instances, err := exec.RecoverInstances(context.Background()) + instances, err := exec.RecoverInstances(context.Background(), nil) if err != nil { t.Errorf("expected nil error, got: %v", err) } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes.go b/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes.go index 643973036a..234a5dcb04 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes.go @@ -20,6 +20,7 @@ import ( agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" ) const ( @@ -612,7 +613,7 @@ func kubernetesProfileConfigFromMetadata(metadata map[string]interface{}) (kubee return kubeexecutor.ParseProfileConfig(values) } -func (r *KubernetesExecutor) RecoverInstances(context.Context) ([]*ExecutorInstance, error) { +func (r *KubernetesExecutor) RecoverInstances(context.Context, []*models.ExecutorRunning) ([]*ExecutorInstance, error) { return nil, nil } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_registry.go b/apps/backend/internal/agent/runtime/lifecycle/executor_registry.go index 78cc2bb758..b4481dbbea 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_registry.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_registry.go @@ -10,6 +10,7 @@ import ( "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" ) // ErrExecutorNotFound is returned when a runtime doesn't exist in the registry. @@ -116,10 +117,13 @@ func (r *ExecutorRegistry) CloseAll() { } } -// RecoverAll recovers instances from all registered runtimes. +// RecoverAll recovers instances from all registered runtimes. records is the +// live standalone recovery-inventory read at startup step 3 +// (AC-EXECUTORS-SURVIVAL-002.8); it is passed to every registered runtime +// unchanged, and only the standalone runtime acts on it today. // Returns all recovered instances and any error encountered. // If multiple runtimes fail, only the last error is returned. -func (r *ExecutorRegistry) RecoverAll(ctx context.Context) ([]*ExecutorInstance, error) { +func (r *ExecutorRegistry) RecoverAll(ctx context.Context, records []*models.ExecutorRunning) ([]*ExecutorInstance, error) { r.mu.RLock() backends := make(map[executor.Name]ExecutorBackend, len(r.backends)) for name, rt := range r.backends { @@ -131,7 +135,7 @@ func (r *ExecutorRegistry) RecoverAll(ctx context.Context) ([]*ExecutorInstance, var lastErr error for name, rt := range backends { - instances, err := rt.RecoverInstances(ctx) + instances, err := rt.RecoverInstances(ctx, records) if err != nil { r.logger.Error("failed to recover instances from runtime", zap.String("runtime", string(name)), diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_registry_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_registry_test.go index e362eed719..f449897de2 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_registry_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_registry_test.go @@ -9,6 +9,7 @@ import ( "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" ) // MockExecutor implements Runtime interface for testing @@ -17,6 +18,13 @@ type MockExecutor struct { healthCheckErr error recoverInstances []*ExecutorInstance recoverErr error + // recoverRecords captures the records RecoverInstances was called with, + // so tests can assert RecoverAll passes them through unchanged. + recoverRecords []*models.ExecutorRunning + // stopInstanceCalls records every instance StopInstance was called with, + // so tests can assert a refused recovered instance was actually stopped. + stopInstanceCalls []*ExecutorInstance + stopInstanceErr error } func (m *MockExecutor) Name() executor.Name { return m.name } @@ -27,9 +35,11 @@ func (m *MockExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRe return nil, nil } func (m *MockExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error { - return nil + m.stopInstanceCalls = append(m.stopInstanceCalls, instance) + return m.stopInstanceErr } -func (m *MockExecutor) RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) { +func (m *MockExecutor) RecoverInstances(ctx context.Context, records []*models.ExecutorRunning) ([]*ExecutorInstance, error) { + m.recoverRecords = records return m.recoverInstances, m.recoverErr } func (m *MockExecutor) GetInteractiveRunner() *process.InteractiveRunner { @@ -224,7 +234,7 @@ func TestExecutorRegistry_RecoverAll(t *testing.T) { registry.Register(&MockExecutor{name: executor.NameDocker, recoverInstances: nil}) registry.Register(&MockExecutor{name: executor.NameStandalone, recoverInstances: []*ExecutorInstance{}}) - instances, err := registry.RecoverAll(ctx) + instances, err := registry.RecoverAll(ctx, nil) if err != nil { t.Errorf("unexpected error: %v", err) } @@ -248,7 +258,7 @@ func TestExecutorRegistry_RecoverAll(t *testing.T) { registry.Register(&MockExecutor{name: executor.NameDocker, recoverInstances: dockerInstances}) registry.Register(&MockExecutor{name: executor.NameStandalone, recoverInstances: standaloneInstances}) - instances, err := registry.RecoverAll(ctx) + instances, err := registry.RecoverAll(ctx, nil) if err != nil { t.Errorf("unexpected error: %v", err) } @@ -269,7 +279,7 @@ func TestExecutorRegistry_RecoverAll(t *testing.T) { registry.Register(&MockExecutor{name: executor.NameDocker, recoverErr: recoverErr}) registry.Register(&MockExecutor{name: executor.NameStandalone, recoverInstances: standaloneInstances}) - instances, err := registry.RecoverAll(ctx) + instances, err := registry.RecoverAll(ctx, nil) // Should still get instances from successful runtimes // Note: map iteration order is not guaranteed, so we check that at least standalone instances are recovered if len(instances) == 0 && err == nil { @@ -283,7 +293,7 @@ func TestExecutorRegistry_RecoverAll(t *testing.T) { log := newTestRegistryLogger() registry := NewExecutorRegistry(log) - instances, err := registry.RecoverAll(ctx) + instances, err := registry.RecoverAll(ctx, nil) if err != nil { t.Errorf("unexpected error: %v", err) } @@ -291,6 +301,33 @@ func TestExecutorRegistry_RecoverAll(t *testing.T) { t.Errorf("expected no instances, got %d", len(instances)) } }) + + // TestExecutorRegistry_RecoverAllPassesRecordsThroughUnchanged pins that + // the live-standalone recovery-inventory records read at startup step 3 + // reach every registered runtime's RecoverInstances unchanged, even one + // that ultimately ignores them (design 03's "producer receives records + // as a parameter"; five of six executors are exactly such an ignorer). + t.Run("records pass through unchanged to every runtime", func(t *testing.T) { + log := newTestRegistryLogger() + registry := NewExecutorRegistry(log) + + docker := &MockExecutor{name: executor.NameDocker} + standalone := &MockExecutor{name: executor.NameStandalone} + registry.Register(docker) + registry.Register(standalone) + + records := []*models.ExecutorRunning{{ID: "row-1", SessionID: "session-1"}} + if _, err := registry.RecoverAll(ctx, records); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(docker.recoverRecords) != 1 || docker.recoverRecords[0] != records[0] { + t.Errorf("docker executor recoverRecords = %v, want the same records slice", docker.recoverRecords) + } + if len(standalone.recoverRecords) != 1 || standalone.recoverRecords[0] != records[0] { + t.Errorf("standalone executor recoverRecords = %v, want the same records slice", standalone.recoverRecords) + } + }) } func TestExecutorRegistry_ThreadSafety(t *testing.T) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_remote_docker.go b/apps/backend/internal/agent/runtime/lifecycle/executor_remote_docker.go index e879fd39b4..2f339b95eb 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_remote_docker.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_remote_docker.go @@ -9,6 +9,7 @@ import ( "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" ) // RemoteDockerExecutor implements Runtime for remote Docker-based agent execution. @@ -57,7 +58,7 @@ func (r *RemoteDockerExecutor) StopInstance(ctx context.Context, instance *Execu return fmt.Errorf("remote_docker runtime is not yet implemented") } -func (r *RemoteDockerExecutor) RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) { +func (r *RemoteDockerExecutor) RecoverInstances(ctx context.Context, _ []*models.ExecutorRunning) ([]*ExecutorInstance, error) { // Remote docker instances are not recovered on restart. // The containers on remote hosts are ephemeral. return nil, nil diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_running_live_standalone_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_running_live_standalone_test.go new file mode 100644 index 0000000000..3e2a863a94 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_running_live_standalone_test.go @@ -0,0 +1,56 @@ +package lifecycle + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +// listingWriter is a fake ExecutorRunningWriter that also implements the +// optional executorRunningLister capability, so a test can pin +// Manager.ListLiveStandaloneExecutorsRunning's delegation without a real DB. +type listingWriter struct { + invariantWriter + rows []*models.ExecutorRunning + err error +} + +func (w *listingWriter) ListExecutorsRunningLiveStandalone(context.Context) ([]*models.ExecutorRunning, error) { + return w.rows, w.err +} + +// TestListLiveStandaloneExecutorsRunningDelegatesToWriter pins that the +// manager forwards to the writer's optional lister capability when present. +// This inventory is read at startup step 3, before any control-server +// contact, to build the recovery guard/correlation set (discovery H). +func TestListLiveStandaloneExecutorsRunningDelegatesToWriter(t *testing.T) { + mgr := newTestManager(t) + want := []*models.ExecutorRunning{{SessionID: "session-1"}} + mgr.SetExecutorRunningWriter(&listingWriter{rows: want}) + + got, err := mgr.ListLiveStandaloneExecutorsRunning(context.Background()) + if err != nil { + t.Fatalf("ListLiveStandaloneExecutorsRunning: %v", err) + } + if len(got) != 1 || got[0].SessionID != "session-1" { + t.Fatalf("got = %#v, want %#v", got, want) + } +} + +// TestListLiveStandaloneExecutorsRunningWithoutListerReturnsEmpty pins the +// best-effort fallback: a writer that doesn't support listing (e.g. a test +// double) yields no candidates rather than an error, matching the rest of +// this file's optional-interface pattern. +func TestListLiveStandaloneExecutorsRunningWithoutListerReturnsEmpty(t *testing.T) { + mgr := newTestManager(t) + mgr.SetExecutorRunningWriter(&invariantWriter{}) + + got, err := mgr.ListLiveStandaloneExecutorsRunning(context.Background()) + if err != nil { + t.Fatalf("ListLiveStandaloneExecutorsRunning: %v", err) + } + if len(got) != 0 { + t.Fatalf("got = %#v, want empty", got) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go index 32a706700d..ba9ac4e948 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go @@ -587,7 +587,7 @@ func (r *SpritesExecutor) buildInstanceResult( } } -func (r *SpritesExecutor) RecoverInstances(_ context.Context) ([]*ExecutorInstance, error) { +func (r *SpritesExecutor) RecoverInstances(_ context.Context, _ []*models.ExecutorRunning) ([]*ExecutorInstance, error) { return nil, nil } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_remote_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_remote_test.go index 9f6fe53286..5493fe6e0e 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_remote_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_remote_test.go @@ -86,7 +86,7 @@ func TestSpritesExecutorStaticSurface(t *testing.T) { if exec.GetInteractiveRunner() != nil { t.Fatal("sprites have no host-side interactive runner") } - instances, err := exec.RecoverInstances(context.Background()) + instances, err := exec.RecoverInstances(context.Background(), nil) if err != nil || instances != nil { t.Fatalf("RecoverInstances() = %v, %v", instances, err) } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go index 7114b0afe2..e5b316d066 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go @@ -912,7 +912,7 @@ func instanceID(instance *ExecutorInstance) string { // Recovery semantics are documented in the spec; persisted metadata keys // (ssh_host / ssh_user / ssh_remote_agentctl_port / etc.) are honored by // ResumeRemoteInstance below. -func (r *SSHExecutor) RecoverInstances(_ context.Context) ([]*ExecutorInstance, error) { +func (r *SSHExecutor) RecoverInstances(_ context.Context, _ []*models.ExecutorRunning) ([]*ExecutorInstance, error) { return nil, nil } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_lifecycle_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_lifecycle_test.go index ee6633e67e..307097e7f7 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_lifecycle_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_lifecycle_test.go @@ -76,7 +76,7 @@ func TestSSHExecutorStaticSurface(t *testing.T) { if exec.GetInteractiveRunner() != nil { t.Fatal("SSH has no host-side interactive runner") } - instances, err := exec.RecoverInstances(context.Background()) + instances, err := exec.RecoverInstances(context.Background(), nil) if err != nil || instances != nil { t.Fatalf("RecoverInstances() = %v, %v; want nil, nil", instances, err) } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go index fee46b5c06..eb387a52af 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go @@ -3,6 +3,7 @@ package lifecycle import ( "context" "fmt" + "sync" "time" "go.uber.org/zap" @@ -13,17 +14,49 @@ import ( "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/subproc" + "github.com/kandev/kandev/internal/task/models" ) +// defaultRecoveryReadTimeout and defaultRecoveryReadRetries are the +// AC-EXECUTORS-SURVIVAL-002.13 defaults (two seconds, two retries), used +// whenever SetRecoveryRetryConfig has not installed a configured value. +const ( + defaultRecoveryReadTimeout = 2 * time.Second + defaultRecoveryReadRetries = 2 +) + +// UnstoppableSessionRecorder is the recovery-guard sink StandaloneExecutor +// drives for a session's not-re-tracked outcome during recovery: retains a +// session's guard for the rest of this backend's lifetime when +// AC-EXECUTORS-SURVIVAL-002.16 applies (at least one live instance could not +// be stopped despite AC-EXECUTORS-SURVIVAL-002.15's bounded retry), marks a +// session whose stop is still resolving when the AC-EXECUTORS-SURVIVAL-003.7 +// recovery deadline elapses mid-retry, and releases that guard once the +// resolution is known. Satisfied structurally by *lifecycle.RecoveryGuard +// (see Manager.RecoveryGuard). +type UnstoppableSessionRecorder interface { + RetainAsUnstoppable(sessionID string) + // MarkStopInFlight records that sessionID's guard must survive + // ReleaseAllExceptRetained until its still-resolving stop is known, + // rather than being released at the deadline bound. + MarkStopInFlight(sessionID string) + // Release drops sessionID's guard once its resolution is known. A no-op + // for a session RetainAsUnstoppable has already claimed. + Release(sessionID string) +} + // StandaloneExecutor implements Runtime for standalone agentctl execution. // In this mode, a single agentctl control server manages multiple agent instances. type StandaloneExecutor struct { - ctl *agentctl.ControlClient - host string - port int - authToken string // per-launch auth token from launcher - logger *logger.Logger - interactiveRunner *process.InteractiveRunner + ctl *agentctl.ControlClient + host string + port int + authToken string // per-launch auth token from launcher + logger *logger.Logger + interactiveRunner *process.InteractiveRunner + recoveryReadTimeout time.Duration + recoveryReadRetries int + unstoppableRecorder UnstoppableSessionRecorder } // NewStandaloneExecutor creates a new standalone runtime. @@ -33,6 +66,10 @@ func NewStandaloneExecutor(ctl *agentctl.ControlClient, host string, port int, l host: host, port: port, logger: log.WithFields(zap.String("runtime", "standalone")), + // -1 is the "unset" sentinel: zero is itself a valid configured + // retry count (AC-EXECUTORS-SURVIVAL-002.13 allows zero retries), so + // the zero value of an unset int field can't be used to mean unset. + recoveryReadRetries: -1, } } @@ -41,6 +78,78 @@ func (r *StandaloneExecutor) SetAuthToken(token string) { r.authToken = token } +// SetRecoveryRetryConfig installs the bounded per-attempt timeout and retry +// count AC-EXECUTORS-SURVIVAL-002.13/002.15 require for recovery reads and +// stops. A zero timeout or negative retry count falls back to the two-second/ +// two-retry default the AC itself specifies. +func (r *StandaloneExecutor) SetRecoveryRetryConfig(timeout time.Duration, retries int) { + r.recoveryReadTimeout = timeout + r.recoveryReadRetries = retries +} + +// SetUnstoppableSessionRecorder installs the AC-EXECUTORS-SURVIVAL-002.16 sink. +// Unset means an unstoppable instance is only logged, never retained -- +// acceptable for callers that haven't wired a recovery guard. +func (r *StandaloneExecutor) SetUnstoppableSessionRecorder(recorder UnstoppableSessionRecorder) { + r.unstoppableRecorder = recorder +} + +// stopWithRetry stops instanceID within the bounded per-attempt timeout and +// retry count of AC-EXECUTORS-SURVIVAL-002.13/002.15, with no delay between +// attempts. DeleteInstance itself already treats an already-absent instance +// (404) as success. +func (r *StandaloneExecutor) stopWithRetry(ctx context.Context, instanceID string) error { + timeout := r.recoveryReadTimeout + if timeout <= 0 { + timeout = defaultRecoveryReadTimeout + } + retries := r.recoveryReadRetries + if retries < 0 { + retries = defaultRecoveryReadRetries + } + + var lastErr error + for attempt := 0; attempt <= retries; attempt++ { + attemptCtx, cancel := context.WithTimeout(ctx, timeout) + lastErr = r.ctl.DeleteInstance(attemptCtx, instanceID) + cancel() + if lastErr == nil { + return nil + } + } + return lastErr +} + +// listInstancesWithRetry enumerates the adopted control server's live +// instances within the same bounded per-attempt timeout and retry count as +// stopWithRetry (AC-EXECUTORS-SURVIVAL-002.13): this enumeration is the sole +// read source for two AC-EXECUTORS-SURVIVAL-002.3 reconstruction-table +// values -- workspace source roots and provider session identity -- both +// declared to come from the adopted instance, so a transient failure here +// must be retried before an instance's reconstruction is given up on. +func (r *StandaloneExecutor) listInstancesWithRetry(ctx context.Context) ([]*agentctl.InstanceInfo, error) { + timeout := r.recoveryReadTimeout + if timeout <= 0 { + timeout = defaultRecoveryReadTimeout + } + retries := r.recoveryReadRetries + if retries < 0 { + retries = defaultRecoveryReadRetries + } + + var lastErr error + for attempt := 0; attempt <= retries; attempt++ { + attemptCtx, cancel := context.WithTimeout(ctx, timeout) + instances, err := r.ctl.ListInstances(attemptCtx) + cancel() + if err == nil { + return instances, nil + } + lastErr = err + } + return nil, lastErr +} + func (r *StandaloneExecutor) Name() executor.Name { return executor.NameStandalone } @@ -217,10 +326,320 @@ func (r *StandaloneExecutor) StopInstance(ctx context.Context, instance *Executo return nil } -func (r *StandaloneExecutor) RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) { - // Standalone instances are not persisted - they are transient processes - // managed by agentctl. Session resume will restart them as needed. - return nil, nil +// RecoverInstances enumerates the adopted control server's live instances and +// correlates them to the live standalone recovery-inventory records read at +// startup step 3 (AC-EXECUTORS-SURVIVAL-002.1/002.8), applying the +// AC-EXECUTORS-SURVIVAL-002.10 duplicate tiebreak via CorrelateRecoveryInstances. +// Every losing duplicate, ambiguous-session instance, and record-less orphan +// (AC-EXECUTORS-SURVIVAL-002.6) is stopped within the bounded retry budget of +// AC-EXECUTORS-SURVIVAL-002.13/002.15 (SetRecoveryRetryConfig); every winner +// is returned for the caller to re-track. When a losing duplicate's stop +// exhausts its retries, that session's winner is also stopped and dropped +// from the result rather than re-tracked (AC-EXECUTORS-SURVIVAL-002.15); if +// the winner's own stop then also fails, the session is reported to the +// installed UnstoppableSessionRecorder (AC-EXECUTORS-SURVIVAL-002.16). +// +// Every stop runs in its own goroutine against context.Background(), never +// ctx: if ctx carries a deadline (AC-EXECUTORS-SURVIVAL-003.7) and it elapses +// while stops are still outstanding, this stops WAITING for them -- treating +// every session whose losing duplicate hasn't yet resolved as not re-tracked +// right now -- but never cancels work already dispatched. Outstanding stops, +// and any winner-stop or unstoppable report they trigger, keep running and +// recording their outcome in the background after this call returns. +// +// Enumeration itself is retried within the AC-EXECUTORS-SURVIVAL-002.13 +// bounded budget (listInstancesWithRetry) before being treated as failed -- +// workspace source roots and provider session identity are read back from +// this same response, so a transient enumeration failure must not skip the +// retry that AC applies to every other adopted-instance read. When the +// adopted server still cannot be enumerated after retries are exhausted, +// this reports nothing recovered, stops nothing, and leaves every record to +// the existing stale-execution repair path (AC-EXECUTORS-SURVIVAL-002.12) +// rather than treating an enumeration failure as though every instance were +// an orphan. +func (r *StandaloneExecutor) RecoverInstances(ctx context.Context, records []*models.ExecutorRunning) ([]*ExecutorInstance, error) { + instances, err := r.listInstancesWithRetry(ctx) + if err != nil { + r.logger.Warn("failed to enumerate standalone instances for recovery; leaving every record to the existing repair path", + zap.Error(err)) + return nil, nil + } + + correlation := CorrelateRecoveryInstances(records, instances) + + // winnersBySession is a stable snapshot of every session's recovered + // winner, independent of correlation.Winners (which this call mutates + // and returns): AC-EXECUTORS-SURVIVAL-003.7 requires an in-flight loser + // stop, once dispatched, to still be able to find and stop its session's + // winner even after that session has already been dropped from the + // returned map at the deadline. + winnersBySession := make(map[string]*agentctl.InstanceInfo, len(correlation.Winners)) + for sessionID, winner := range correlation.Winners { + winnersBySession[sessionID] = winner + } + + pending := pendingLoserCounts(correlation.ToStop, correlation.Winners) + results := r.dispatchRecoveryStops(correlation.ToStop) + tracker := &jointFailureTracker{exec: r, winners: winnersBySession} + r.collectRecoveryStops(ctx, results, correlation.Winners, pending, tracker) + + return r.buildRecoveredInstances(correlation.Winners, indexRecordsBySession(records)), nil +} + +// recoveryStopOutcome is one instance's bounded stop attempt result. +type recoveryStopOutcome struct { + inst *agentctl.InstanceInfo + err error +} + +// dispatchRecoveryStops starts one goroutine per not-re-tracked instance, +// each stopping it within the bounded retry budget against +// context.Background() -- never the caller's ctx, so an elapsed recovery +// deadline can never abort a stop already in flight +// (AC-EXECUTORS-SURVIVAL-003.7). The returned channel is closed once every +// goroutine has reported. +func (r *StandaloneExecutor) dispatchRecoveryStops(toStop []*agentctl.InstanceInfo) <-chan recoveryStopOutcome { + results := make(chan recoveryStopOutcome, len(toStop)) + var wg sync.WaitGroup + for _, inst := range toStop { + wg.Add(1) + go func(inst *agentctl.InstanceInfo) { + defer wg.Done() + results <- recoveryStopOutcome{inst: inst, err: r.stopWithRetry(context.Background(), inst.ID)} + }(inst) + } + go func() { + wg.Wait() + close(results) + }() + return results +} + +// pendingLoserCounts counts, per session with a winner, how many of its +// losing duplicates have not yet reported a stop outcome. +func pendingLoserCounts(toStop []*agentctl.InstanceInfo, winners map[string]*agentctl.InstanceInfo) map[string]int { + pending := make(map[string]int, len(winners)) + for _, inst := range toStop { + if _, hasWinner := winners[inst.SessionID]; hasWinner { + pending[inst.SessionID]++ + } + } + return pending +} + +// collectRecoveryStops waits for dispatchRecoveryStops's results, applying +// each as it arrives, until either every result has landed or ctx's deadline +// (if any) elapses first. On deadline elapse it synchronously drops every +// session with an outstanding loser stop from winners -- AC-EXECUTORS-SURVIVAL-003.7's +// "treated as not-re-tracked" -- then keeps draining the remaining results in +// the background so their outcomes (including a later AC-EXECUTORS-SURVIVAL-002.16 +// report) still get recorded. +// +// winners is only ever mutated from this call's own goroutine, and only +// before this function returns: the caller iterates it immediately after +// (buildRecoveredInstances), and the background drain goroutine started at +// the deadline branch never touches it, to avoid a concurrent map +// read/write with that iteration. +func (r *StandaloneExecutor) collectRecoveryStops( + ctx context.Context, + results <-chan recoveryStopOutcome, + winners map[string]*agentctl.InstanceInfo, + pending map[string]int, + tracker *jointFailureTracker, +) { + deadlineCh, stopTimer := recoveryDeadlineChannel(ctx) + defer stopTimer() + + for { + select { + case res, ok := <-results: + if !ok { + return + } + r.handleRecoveryStopResult(res, pending, tracker) + if res.err != nil { + delete(winners, res.inst.SessionID) + } + case <-deadlineCh: + for sessionID, n := range pending { + if n > 0 { + delete(winners, sessionID) + // AC-EXECUTORS-SURVIVAL-003.7: this session's stop is still + // retrying past the deadline -- its guard must survive + // ReleaseAllExceptRetained until drainRecoveryStopsAfterDeadline + // below learns the resolution and releases (or retains) it. + if r.unstoppableRecorder != nil { + r.unstoppableRecorder.MarkStopInFlight(sessionID) + } + } + } + go r.drainRecoveryStopsAfterDeadline(results, pending, tracker) + return + } + } +} + +// drainRecoveryStopsAfterDeadline finishes applying every recovery stop +// outcome still outstanding once collectRecoveryStops has already returned +// past the AC-EXECUTORS-SURVIVAL-003.7 deadline. Each affected session's +// MarkStopInFlight guard is released once every one of its pending stops has +// resolved: either directly here (every loser stopped, nothing left to +// jointly stop) or by stopWinnerAfterLoserFailure's own terminal Release/ +// RetainAsUnstoppable call -- so the "retain until that stop resolves" +// exception never outlives the actual resolution. Release is safe to call a +// second time here even after stopWinnerAfterLoserFailure already resolved +// the guard: it no-ops for an already-released or already-retained session. +func (r *StandaloneExecutor) drainRecoveryStopsAfterDeadline( + results <-chan recoveryStopOutcome, + pending map[string]int, + tracker *jointFailureTracker, +) { + for res := range results { + r.handleRecoveryStopResult(res, pending, tracker) + if r.unstoppableRecorder != nil && pending[res.inst.SessionID] == 0 { + r.unstoppableRecorder.Release(res.inst.SessionID) + } + } +} + +// recoveryDeadlineChannel returns a channel that fires at ctx's deadline, or +// a nil channel (which never fires) when ctx carries none. +func recoveryDeadlineChannel(ctx context.Context) (<-chan time.Time, func()) { + deadline, ok := ctx.Deadline() + if !ok { + return nil, func() {} + } + timer := time.NewTimer(time.Until(deadline)) + return timer.C, func() { timer.Stop() } +} + +// handleRecoveryStopResult applies one stop outcome: on success it only +// clears the session's pending-loser count; on failure it also logs and, for +// a losing duplicate whose session has a winner, triggers the joint failure +// handling (AC-EXECUTORS-SURVIVAL-002.15). +func (r *StandaloneExecutor) handleRecoveryStopResult(res recoveryStopOutcome, pending map[string]int, tracker *jointFailureTracker) { + sessionID := res.inst.SessionID + if _, tracked := pending[sessionID]; tracked { + pending[sessionID]-- + } + if res.err == nil { + return + } + r.logger.Warn("failed to stop a not-re-tracked standalone instance during recovery after exhausting retries", + zap.String("instance_id", res.inst.ID), zap.String("session_id", sessionID), zap.Error(res.err)) + tracker.onLoserStopFailed(sessionID) +} + +// jointFailureTracker ensures AC-EXECUTORS-SURVIVAL-002.15/002.16's joint +// winner-stop-and-possible-unstoppable-report handling runs at most once per +// session, whether triggered synchronously (before the recovery deadline) or +// from the background drain after it (AC-EXECUTORS-SURVIVAL-003.7). +type jointFailureTracker struct { + exec *StandaloneExecutor + winners map[string]*agentctl.InstanceInfo + handled sync.Map // sessionID -> *sync.Once +} + +func (t *jointFailureTracker) onLoserStopFailed(sessionID string) { + winner, ok := t.winners[sessionID] + if !ok { + return // orphan, or a session with no winner: nothing to jointly affect + } + once, _ := t.handled.LoadOrStore(sessionID, &sync.Once{}) + once.(*sync.Once).Do(func() { + // The loser has already proved that this session cannot be made + // single-owner. Retain the guard before stopping the winner so a + // successful winner stop cannot release the protection for a still-live + // loser. + if t.exec.unstoppableRecorder != nil { + t.exec.unstoppableRecorder.RetainAsUnstoppable(sessionID) + } + t.exec.stopWinnerAfterLoserFailure(sessionID, winner) + }) +} + +// stopWinnerAfterLoserFailure stops a session's winning instance on the same +// bounded retry terms after one of its losing duplicates could not be +// stopped. The caller retains the session guard before entering this method, +// and that retention survives either winner-stop result (AC-EXECUTORS- +// SURVIVAL-002.16). Always runs to completion once started, even past the +// recovery deadline (AC-EXECUTORS-SURVIVAL-003.7). +func (r *StandaloneExecutor) stopWinnerAfterLoserFailure(sessionID string, winner *agentctl.InstanceInfo) { + if err := r.stopWithRetry(context.Background(), winner.ID); err != nil { + r.logger.Warn("failed to stop the winning instance after its losing duplicate could not be stopped; retaining session as unstoppable", + zap.String("instance_id", winner.ID), zap.String("session_id", sessionID), zap.Error(err)) + return + } + r.logger.Warn("stopped the winning instance because its losing duplicate could not be stopped; session left not re-tracked", + zap.String("instance_id", winner.ID), zap.String("session_id", sessionID)) +} + +// indexRecordsBySession maps every named recovery-inventory record by +// session ID for use while building recovered executions. +func indexRecordsBySession(records []*models.ExecutorRunning) map[string]*models.ExecutorRunning { + recordBySession := make(map[string]*models.ExecutorRunning, len(records)) + for _, rec := range records { + if rec != nil && rec.SessionID != "" { + recordBySession[rec.SessionID] = rec + } + } + return recordBySession +} + +// buildRecoveredInstances converts every still-winning correlated instance +// into an ExecutorInstance for the caller to re-track. +func (r *StandaloneExecutor) buildRecoveredInstances( + winners map[string]*agentctl.InstanceInfo, + recordBySession map[string]*models.ExecutorRunning, +) []*ExecutorInstance { + recovered := make([]*ExecutorInstance, 0, len(winners)) + for sessionID, inst := range winners { + record := recordBySession[sessionID] + + client := agentctl.NewClient(r.host, inst.Port, r.logger, + agentctl.WithExecutionID(inst.ID), + agentctl.WithSessionID(sessionID), + agentctl.WithAuthToken(r.authToken)) + + // AC-EXECUTORS-SURVIVAL-002.14: task identity and workspace path's + // declared source is the recovery-inventory record, never the + // adopted instance -- an instance must not be able to influence what + // the backend believes either belongs to. A record with no TaskID + // (or, defensively, no record at all -- correlation should never + // produce a winner without one) is left empty here rather than + // falling back to the instance's self-reported value; the caller + // routes an empty TaskID to the AC-EXECUTORS-SURVIVAL-002.4 refusal + // path instead of tracking a session it cannot safely operate. + var taskID, workspacePath string + var metadata map[string]interface{} + var agentProfileID string + if record != nil { + taskID = record.TaskID + workspacePath = record.WorktreePath + metadata = record.Metadata + // AC-EXECUTORS-SURVIVAL-002.14: agent profile identity's declared + // source is the recovery-inventory record's execution-profile + // column -- never the adopted instance. + agentProfileID = record.ExecutionProfileID + } + + recovered = append(recovered, &ExecutorInstance{ + InstanceID: inst.ID, + TaskID: taskID, + SessionID: sessionID, + AgentProfileID: agentProfileID, + RuntimeName: r.Name(), + Client: client, + StandaloneInstanceID: inst.ID, + StandalonePort: inst.Port, + WorkspacePath: workspacePath, + Metadata: metadata, + Env: inst.Env, + WorkspaceSourceRoots: inst.WorkspaceSourceRoots, + ProviderSessionID: inst.ProviderSessionID, + }) + } + return recovered } // SetInteractiveRunner sets the interactive runner for passthrough mode. diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone_test.go index 7a0e9d4509..be46aa9477 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone_test.go @@ -3,6 +3,7 @@ package lifecycle import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "net/url" @@ -16,6 +17,8 @@ import ( "github.com/kandev/kandev/internal/agent/executor" agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/task/models" ) // standaloneControlServer is an in-process stand-in for the host agentctl @@ -28,11 +31,64 @@ type standaloneControlServer struct { deleted []string createStatus int server *httptest.Server + // listInstances is what GET /api/v1/instances returns; nil (vs. an empty + // non-nil slice) tells the fake to answer with a listing failure instead, + // for exercising AC-EXECUTORS-SURVIVAL-002.12. + listInstances []*agentctlclient.InstanceInfo + listInstancesErr bool + // listInstancesFailures is the number of remaining GET + // /api/v1/instances attempts to fail (500) before it starts answering + // with listInstances -- lets a test pin the exact bounded-retry attempt + // count (AC-EXECUTORS-SURVIVAL-002.13) for the enumeration read itself. + listInstancesFailures int + listInstancesAttempts int + // deleteFailures, keyed by instance ID, is the number of remaining DELETE + // attempts to fail (500) for that instance before it starts succeeding -- + // lets a test pin the exact bounded-retry attempt count + // (AC-EXECUTORS-SURVIVAL-002.15). A count so high it never reaches zero + // simulates a stop that never succeeds. + deleteFailures map[string]int + deleteAttempts map[string]int + // deleteDelay, keyed by instance ID, makes that instance's DELETE + // response take at least this long -- used to prove a recovery deadline + // (AC-EXECUTORS-SURVIVAL-003.7) can elapse while a stop is genuinely + // still in flight, without the fixture holding s.mu for the whole sleep + // (which would otherwise serialize every other concurrent stop request). + deleteDelay map[string]time.Duration + + // turnOutcomes, keyed by instance ID, is what GET + // .../turn-outcome answers with; an absent key answers "nothing + // retained" (retained: false), matching AC-EXECUTORS-SURVIVAL-004.5's + // no-outcome case. + turnOutcomes map[string]agentctlclient.TurnOutcome + // turnOutcomeFailures, keyed by instance ID, is the number of remaining + // GET .../turn-outcome attempts to fail (500) before it starts + // answering normally -- pins the bounded-retry attempt count + // (AC-EXECUTORS-SURVIVAL-004.5/002.13). + turnOutcomeFailures map[string]int + turnOutcomeAttempts map[string]int + // ackedTurnOutcomes records every accepted POST .../turn-outcome/ack + // call, in order. + ackedTurnOutcomes []ackedTurnOutcome +} + +type ackedTurnOutcome struct { + instanceID string + turnID int64 } func newStandaloneControlServer(t *testing.T, healthy bool) *standaloneControlServer { t.Helper() - s := &standaloneControlServer{healthy: healthy, createStatus: http.StatusOK} + s := &standaloneControlServer{ + healthy: healthy, + createStatus: http.StatusOK, + deleteFailures: make(map[string]int), + deleteAttempts: make(map[string]int), + deleteDelay: make(map[string]time.Duration), + turnOutcomes: make(map[string]agentctlclient.TurnOutcome), + turnOutcomeFailures: make(map[string]int), + turnOutcomeAttempts: make(map[string]int), + } s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.mu.Lock() defer s.mu.Unlock() @@ -55,7 +111,61 @@ func newStandaloneControlServer(t *testing.T, healthy bool) *standaloneControlSe } _ = json.NewEncoder(w).Encode(agentctlclient.CreateInstanceResponse{ID: "std-1", Port: 45678}) case strings.HasPrefix(r.URL.Path, "/api/v1/instances/") && r.Method == http.MethodDelete: - s.deleted = append(s.deleted, strings.TrimPrefix(r.URL.Path, "/api/v1/instances/")) + id := strings.TrimPrefix(r.URL.Path, "/api/v1/instances/") + s.deleteAttempts[id]++ + delay := s.deleteDelay[id] + fail := s.deleteFailures[id] > 0 + if fail { + s.deleteFailures[id]-- + } + if delay > 0 { + s.mu.Unlock() + time.Sleep(delay) + s.mu.Lock() + } + if fail { + w.WriteHeader(http.StatusInternalServerError) + return + } + s.deleted = append(s.deleted, id) + case r.URL.Path == "/api/v1/instances" && r.Method == http.MethodGet: + s.listInstancesAttempts++ + if s.listInstancesErr { + w.WriteHeader(http.StatusInternalServerError) + return + } + if s.listInstancesFailures > 0 { + s.listInstancesFailures-- + w.WriteHeader(http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(struct { + Instances []*agentctlclient.InstanceInfo `json:"instances"` + }{Instances: s.listInstances}) + case strings.HasPrefix(r.URL.Path, "/api/v1/instances/") && strings.HasSuffix(r.URL.Path, "/turn-outcome") && + r.Method == http.MethodGet: + id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v1/instances/"), "/turn-outcome") + s.turnOutcomeAttempts[id]++ + if s.turnOutcomeFailures[id] > 0 { + s.turnOutcomeFailures[id]-- + w.WriteHeader(http.StatusInternalServerError) + return + } + outcome, retained := s.turnOutcomes[id] + _ = json.NewEncoder(w).Encode(struct { + Retained bool `json:"retained"` + TurnID int64 `json:"turn_id"` + Event streams.AgentEvent `json:"event"` + }{Retained: retained, TurnID: outcome.TurnID, Event: outcome.Event}) + case strings.HasPrefix(r.URL.Path, "/api/v1/instances/") && strings.HasSuffix(r.URL.Path, "/turn-outcome/ack") && + r.Method == http.MethodPost: + id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v1/instances/"), "/turn-outcome/ack") + var body struct { + TurnID int64 `json:"turn_id"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + s.ackedTurnOutcomes = append(s.ackedTurnOutcomes, ackedTurnOutcome{instanceID: id, turnID: body.TurnID}) + w.WriteHeader(http.StatusOK) default: w.WriteHeader(http.StatusNotFound) } @@ -99,9 +209,9 @@ func TestStandaloneExecutorStaticSurface(t *testing.T) { if exec.IsAlwaysResumable() { t.Fatal("standalone instances are transient and not always resumable") } - instances, err := exec.RecoverInstances(context.Background()) - if err != nil || instances != nil { - t.Fatalf("RecoverInstances() = %v, %v; want nil, nil", instances, err) + instances, err := exec.RecoverInstances(context.Background(), nil) + if err != nil || len(instances) != 0 { + t.Fatalf("RecoverInstances() = %v, %v; want none recovered, no error", instances, err) } runner := &process.InteractiveRunner{} @@ -285,6 +395,527 @@ func TestStandaloneExecutorStopInstance(t *testing.T) { }) } +// TestStandaloneExecutorRecoverInstancesReTracksWinnerAndStopsOrphan pins +// AC-EXECUTORS-SURVIVAL-002.1/002.2/002.6: a live instance whose session +// matches a recovery-inventory record is re-tracked and returned; a live +// instance with no matching record is stopped as an orphan. +func TestStandaloneExecutorRecoverInstancesReTracksWinnerAndStopsOrphan(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{ + { + ID: "instance-1", Port: 5001, SessionID: "session-1", TaskID: "task-1", WorkspacePath: "/ws/1", + Env: map[string]string{"KANDEV_RUN_ID": "run-42", "PATH": "/usr/bin"}, + WorkspaceSourceRoots: []string{"/ws/1", "/ws/1-sibling"}, + ProviderSessionID: "provider-session-9", + }, + {ID: "orphan-instance", Port: 5002, SessionID: "session-orphan"}, + } + exec := control.executor(t) + exec.SetAuthToken("survival-token") + + records := []*models.ExecutorRunning{ + {SessionID: "session-1", TaskID: "task-1", AgentExecutionID: "instance-1", WorktreePath: "/ws/1-from-record", Metadata: map[string]interface{}{"k": "v"}}, + } + + recovered, err := exec.RecoverInstances(context.Background(), records) + if err != nil { + t.Fatalf("RecoverInstances: %v", err) + } + if len(recovered) != 1 { + t.Fatalf("recovered = %+v, want exactly one winner", recovered) + } + got := recovered[0] + if got.SessionID != "session-1" || got.TaskID != "task-1" || got.InstanceID != "instance-1" { + t.Fatalf("recovered instance identity = %+v", got) + } + // AC-EXECUTORS-SURVIVAL-002.14 (Review round 3, finding 3): workspace + // path's declared source is the recovery-inventory record, never the + // adopted instance's own report (which the fixture deliberately sets to + // a different value, "/ws/1", to prove it is ignored). + if got.WorkspacePath != "/ws/1-from-record" { + t.Fatalf("WorkspacePath = %q, want the record's WorktreePath, not the instance's self-reported path", got.WorkspacePath) + } + if got.Metadata["k"] != "v" { + t.Fatalf("Metadata = %+v, want the record's persisted metadata", got.Metadata) + } + if got.Env["KANDEV_RUN_ID"] != "run-42" || got.Env["PATH"] != "/usr/bin" { + t.Fatalf("Env = %+v, want the adopted instance's own runtime environment read back (AC-EXECUTORS-SURVIVAL-002.14)", got.Env) + } + if len(got.WorkspaceSourceRoots) != 2 || got.WorkspaceSourceRoots[0] != "/ws/1" || got.WorkspaceSourceRoots[1] != "/ws/1-sibling" { + t.Fatalf("WorkspaceSourceRoots = %+v, want the adopted instance's own live allowlist read back (AC-EXECUTORS-SURVIVAL-002.14)", got.WorkspaceSourceRoots) + } + if got.ProviderSessionID != "provider-session-9" { + t.Fatalf("ProviderSessionID = %q, want the adopted instance's own live provider session read back (AC-EXECUTORS-SURVIVAL-002.14)", got.ProviderSessionID) + } + if got.Client == nil { + t.Fatal("recovered instance must carry a usable agentctl client") + } + + control.mu.Lock() + defer control.mu.Unlock() + if len(control.deleted) != 1 || control.deleted[0] != "orphan-instance" { + t.Fatalf("deleted = %v, want the orphan instance stopped", control.deleted) + } +} + +// TestStandaloneExecutorRecoverInstancesStopsDuplicateLoser pins +// AC-EXECUTORS-SURVIVAL-002.10: of two live instances for one session, the +// one matching the record's agent execution identifier is re-tracked and the +// other is stopped. +func TestStandaloneExecutorRecoverInstancesStopsDuplicateLoser(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{ + {ID: "winner", Port: 5001, SessionID: "session-1"}, + {ID: "loser", Port: 5002, SessionID: "session-1"}, + } + exec := control.executor(t) + + records := []*models.ExecutorRunning{{SessionID: "session-1", AgentExecutionID: "winner"}} + + recovered, err := exec.RecoverInstances(context.Background(), records) + if err != nil { + t.Fatalf("RecoverInstances: %v", err) + } + if len(recovered) != 1 || recovered[0].InstanceID != "winner" { + t.Fatalf("recovered = %+v, want only the winner", recovered) + } + + control.mu.Lock() + defer control.mu.Unlock() + if len(control.deleted) != 1 || control.deleted[0] != "loser" { + t.Fatalf("deleted = %v, want the loser stopped", control.deleted) + } +} + +// TestStandaloneExecutorRecoverInstancesRecordWithNoInstanceIsUntouched pins +// AC-EXECUTORS-SURVIVAL-002.7: a record with no live instance is left alone, +// not stopped and not reported recovered. +func TestStandaloneExecutorRecoverInstancesRecordWithNoInstanceIsUntouched(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = nil + exec := control.executor(t) + + records := []*models.ExecutorRunning{{SessionID: "session-cold", AgentExecutionID: "instance-cold"}} + + recovered, err := exec.RecoverInstances(context.Background(), records) + if err != nil { + t.Fatalf("RecoverInstances: %v", err) + } + if len(recovered) != 0 { + t.Fatalf("recovered = %+v, want none", recovered) + } + control.mu.Lock() + defer control.mu.Unlock() + if len(control.deleted) != 0 { + t.Fatalf("deleted = %v, want none: a record with no instance is left to the existing repair path", control.deleted) + } +} + +// TestStandaloneExecutorRecoverInstancesEnumerationFailureRecoversNothing +// pins AC-EXECUTORS-SURVIVAL-002.12: when the adopted server cannot be +// enumerated, recovery reports nothing recovered, stops nothing, and does +// not error out (a hard error here would fail backend startup outright). +func TestStandaloneExecutorRecoverInstancesEnumerationFailureRecoversNothing(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstancesErr = true + exec := control.executor(t) + + records := []*models.ExecutorRunning{{SessionID: "session-1", AgentExecutionID: "instance-1"}} + + recovered, err := exec.RecoverInstances(context.Background(), records) + if err != nil { + t.Fatalf("RecoverInstances: %v, want a logged-and-swallowed enumeration failure", err) + } + if len(recovered) != 0 { + t.Fatalf("recovered = %+v, want none", recovered) + } + control.mu.Lock() + defer control.mu.Unlock() + if len(control.deleted) != 0 { + t.Fatalf("deleted = %v, want none: an unenumerable server must not have instances stopped blind", control.deleted) + } +} + +// TestStandaloneExecutorRecoverInstancesRetriesTransientEnumerationFailure +// pins AC-EXECUTORS-SURVIVAL-002.13's bounded read retry applied to the +// enumeration call itself: workspace source roots and provider session +// identity are read back only from this response, so a transient failure +// must be retried, not treated as a hard enumeration failure on the first +// error. +func TestStandaloneExecutorRecoverInstancesRetriesTransientEnumerationFailure(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{ + {ID: "instance-1", Port: 5001, SessionID: "session-1"}, + } + control.listInstancesFailures = 1 // fails once, succeeds on the retry + exec := control.executor(t) + exec.SetRecoveryRetryConfig(50*time.Millisecond, 1) + + records := []*models.ExecutorRunning{{SessionID: "session-1", AgentExecutionID: "instance-1"}} + + recovered, err := exec.RecoverInstances(context.Background(), records) + if err != nil { + t.Fatalf("RecoverInstances: %v", err) + } + if len(recovered) != 1 { + t.Fatalf("recovered = %+v, want the one instance recovered after the retry succeeds", recovered) + } + + control.mu.Lock() + defer control.mu.Unlock() + if control.listInstancesAttempts != 2 { + t.Fatalf("listInstances attempts = %d, want exactly 2 (1 failure plus 1 retry)", control.listInstancesAttempts) + } +} + +// TestStandaloneExecutorRecoverInstancesExhaustingEnumerationRetryRecoversNothing +// pins the exhausted-budget half of AC-EXECUTORS-SURVIVAL-002.13 for the +// enumeration read: once every attempt fails, recovery falls back to +// AC-EXECUTORS-SURVIVAL-002.12's existing repair path rather than retrying +// indefinitely. +func TestStandaloneExecutorRecoverInstancesExhaustingEnumerationRetryRecoversNothing(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{ + {ID: "instance-1", Port: 5001, SessionID: "session-1"}, + } + control.listInstancesFailures = 999 // never succeeds within the retry budget + exec := control.executor(t) + exec.SetRecoveryRetryConfig(20*time.Millisecond, 1) + + records := []*models.ExecutorRunning{{SessionID: "session-1", AgentExecutionID: "instance-1"}} + + recovered, err := exec.RecoverInstances(context.Background(), records) + if err != nil { + t.Fatalf("RecoverInstances: %v, want a logged-and-swallowed exhausted-retry enumeration failure", err) + } + if len(recovered) != 0 { + t.Fatalf("recovered = %+v, want none", recovered) + } + + control.mu.Lock() + defer control.mu.Unlock() + if control.listInstancesAttempts != 2 { + t.Fatalf("listInstances attempts = %d, want exactly 2 (1 retry configured)", control.listInstancesAttempts) + } +} + +// fakeUnstoppableRecorder captures RetainAsUnstoppable/MarkStopInFlight/ +// Release calls without needing a real RecoveryGuard. +type fakeUnstoppableRecorder struct { + mu sync.Mutex + retained []string + markedInFlight []string + released []string +} + +func (f *fakeUnstoppableRecorder) RetainAsUnstoppable(sessionID string) { + f.mu.Lock() + defer f.mu.Unlock() + f.retained = append(f.retained, sessionID) +} + +func (f *fakeUnstoppableRecorder) MarkStopInFlight(sessionID string) { + f.mu.Lock() + defer f.mu.Unlock() + f.markedInFlight = append(f.markedInFlight, sessionID) +} + +func (f *fakeUnstoppableRecorder) Release(sessionID string) { + f.mu.Lock() + defer f.mu.Unlock() + f.released = append(f.released, sessionID) +} + +// TestStandaloneExecutorRecoverInstancesRetriesTransientStopFailure pins +// AC-EXECUTORS-SURVIVAL-002.15: a stop that fails once and then succeeds is +// retried within the bounded count rather than being treated as a permanent +// failure. +func TestStandaloneExecutorRecoverInstancesRetriesTransientStopFailure(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{ + {ID: "orphan-instance", Port: 5002, SessionID: "session-orphan"}, + } + control.deleteFailures["orphan-instance"] = 1 + exec := control.executor(t) + + recovered, err := exec.RecoverInstances(context.Background(), nil) + if err != nil { + t.Fatalf("RecoverInstances: %v", err) + } + if len(recovered) != 0 { + t.Fatalf("recovered = %+v, want none", recovered) + } + + control.mu.Lock() + defer control.mu.Unlock() + if len(control.deleted) != 1 || control.deleted[0] != "orphan-instance" { + t.Fatalf("deleted = %v, want the orphan eventually stopped after a retry", control.deleted) + } + if control.deleteAttempts["orphan-instance"] != 2 { + t.Fatalf("delete attempts = %d, want exactly 2 (one failure plus one retry)", control.deleteAttempts["orphan-instance"]) + } +} + +// TestStandaloneExecutorRecoverInstancesExhaustingLoserStopDropsAndStopsWinner +// pins AC-EXECUTORS-SURVIVAL-002.15: when a losing duplicate's stop exhausts +// its retries, the winning instance for that session is also not re-tracked +// and is itself stopped on the same terms. +func TestStandaloneExecutorRecoverInstancesExhaustingLoserStopDropsAndStopsWinner(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{ + {ID: "winner", Port: 5001, SessionID: "session-1"}, + {ID: "loser", Port: 5002, SessionID: "session-1"}, + } + control.deleteFailures["loser"] = 999 // never succeeds within the retry budget + exec := control.executor(t) + exec.SetRecoveryRetryConfig(50*time.Millisecond, 1) + + records := []*models.ExecutorRunning{{SessionID: "session-1", AgentExecutionID: "winner"}} + + recovered, err := exec.RecoverInstances(context.Background(), records) + if err != nil { + t.Fatalf("RecoverInstances: %v", err) + } + if len(recovered) != 0 { + t.Fatalf("recovered = %+v, want none: the session must not be re-tracked once its loser can't be stopped", recovered) + } + + control.mu.Lock() + defer control.mu.Unlock() + if control.deleteAttempts["loser"] != 2 { + t.Fatalf("loser delete attempts = %d, want exactly 2 (1 retry configured)", control.deleteAttempts["loser"]) + } + if len(control.deleted) != 1 || control.deleted[0] != "winner" { + t.Fatalf("deleted = %v, want the winner also stopped since the session won't be re-tracked", control.deleted) + } +} + +// TestStandaloneExecutorRecoverInstancesLoserFailureWinnerSuccessRetainsGuard +// pins the ownership handoff when only the losing duplicate cannot be +// stopped. A successful winner stop must not release the session guard while +// the loser is still alive. +func TestStandaloneExecutorRecoverInstancesLoserFailureWinnerSuccessRetainsGuard(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{ + {ID: "winner", Port: 5001, SessionID: "session-1"}, + {ID: "loser", Port: 5002, SessionID: "session-1"}, + } + control.deleteFailures["loser"] = 999 + exec := control.executor(t) + exec.SetRecoveryRetryConfig(50*time.Millisecond, 0) + guard := NewRecoveryGuard() + guard.AcquireOrObserve("session-1") + exec.SetUnstoppableSessionRecorder(guard) + + recovered, err := exec.RecoverInstances(context.Background(), []*models.ExecutorRunning{{ + SessionID: "session-1", AgentExecutionID: "winner", + }}) + if err != nil { + t.Fatalf("RecoverInstances: %v", err) + } + if len(recovered) != 0 { + t.Fatalf("recovered = %+v, want none", recovered) + } + if err := guard.CheckLaunchAllowed("session-1"); !errors.Is(err, ErrSessionUnstoppableAgent) { + t.Fatalf("guard after winner stop success = %v, want ErrSessionUnstoppableAgent", err) + } + + control.mu.Lock() + defer control.mu.Unlock() + if len(control.deleted) != 1 || control.deleted[0] != "winner" { + t.Fatalf("deleted = %v, want only winner; the loser stop failed", control.deleted) + } +} + +// TestStandaloneExecutorRecoverInstancesJointStopFailureRetainsSessionUnstoppable +// pins AC-EXECUTORS-SURVIVAL-002.16: when both the losing duplicate's stop +// and the winner's own stop exhaust their retries, the session is recorded +// as unstoppable rather than silently left not-re-tracked. +func TestStandaloneExecutorRecoverInstancesJointStopFailureRetainsSessionUnstoppable(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{ + {ID: "winner", Port: 5001, SessionID: "session-1"}, + {ID: "loser", Port: 5002, SessionID: "session-1"}, + } + control.deleteFailures["loser"] = 999 + control.deleteFailures["winner"] = 999 + exec := control.executor(t) + exec.SetRecoveryRetryConfig(50*time.Millisecond, 0) + recorder := &fakeUnstoppableRecorder{} + exec.SetUnstoppableSessionRecorder(recorder) + + records := []*models.ExecutorRunning{{SessionID: "session-1", AgentExecutionID: "winner"}} + + recovered, err := exec.RecoverInstances(context.Background(), records) + if err != nil { + t.Fatalf("RecoverInstances: %v", err) + } + if len(recovered) != 0 { + t.Fatalf("recovered = %+v, want none", recovered) + } + + recorder.mu.Lock() + defer recorder.mu.Unlock() + if len(recorder.retained) != 1 || recorder.retained[0] != "session-1" { + t.Fatalf("retained = %v, want session-1 retained as unstoppable", recorder.retained) + } +} + +// TestStandaloneExecutorRecoverInstancesDeadlineDropsPendingSessionAndFinishesInBackground +// pins AC-EXECUTORS-SURVIVAL-003.7: once ctx's deadline elapses, a session +// whose losing duplicate hasn't finished stopping yet is treated as +// not-re-tracked immediately -- RecoverInstances returns without waiting for +// it -- but the in-flight stop, and the consequent joint winner-stop and +// AC-EXECUTORS-SURVIVAL-002.16 unstoppable report it triggers, are not +// aborted: they run to completion in the background and still get recorded. +func TestStandaloneExecutorRecoverInstancesDeadlineDropsPendingSessionAndFinishesInBackground(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{ + {ID: "winner", Port: 5001, SessionID: "session-1"}, + {ID: "loser", Port: 5002, SessionID: "session-1"}, + } + control.deleteFailures["loser"] = 999 // never succeeds within its retry budget + control.deleteFailures["winner"] = 999 // never succeeds either, once tried + control.deleteDelay["loser"] = 150 * time.Millisecond + exec := control.executor(t) + exec.SetRecoveryRetryConfig(20*time.Millisecond, 0) + recorder := &fakeUnstoppableRecorder{} + exec.SetUnstoppableSessionRecorder(recorder) + + records := []*models.ExecutorRunning{{SessionID: "session-1", AgentExecutionID: "winner"}} + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + start := time.Now() + recovered, err := exec.RecoverInstances(ctx, records) + elapsed := time.Since(start) + if err != nil { + t.Fatalf("RecoverInstances: %v", err) + } + if len(recovered) != 0 { + t.Fatalf("recovered = %+v, want none: the session's loser stop was still unresolved at the deadline", recovered) + } + if elapsed > 100*time.Millisecond { + t.Fatalf("RecoverInstances took %s, want it to return promptly at the deadline instead of waiting for the in-flight stop", elapsed) + } + + pollDeadline := time.Now().Add(2 * time.Second) + for { + recorder.mu.Lock() + retained := len(recorder.retained) + recorder.mu.Unlock() + if retained == 1 { + break + } + if time.Now().After(pollDeadline) { + t.Fatal("timed out waiting for the background drain to finish joint-failure handling") + } + time.Sleep(5 * time.Millisecond) + } + + recorder.mu.Lock() + defer recorder.mu.Unlock() + if recorder.retained[0] != "session-1" { + t.Fatalf("retained = %v, want session-1", recorder.retained) + } +} + +// TestStandaloneExecutorRecoverInstancesDeadlineMarksGuardInFlightAndReleasesOnResolution +// pins Review round 2 finding 3 (AC-EXECUTORS-SURVIVAL-002.8/003.7): a +// session dropped at the recovery deadline because its loser's stop is still +// in flight must have its guard marked stop-in-flight, so a Start() pass's +// ReleaseAllExceptRetained leaves it held rather than releasing it while a +// launch racing the still-resolving stop could still recreate two live +// agents for the session -- and the guard must actually be released once the +// background drain learns the stop resolved, not left held forever. Uses the +// real RecoveryGuard rather than a fake so both state transitions are pinned +// against the type Manager.Start actually wires in. +func TestStandaloneExecutorRecoverInstancesDeadlineMarksGuardInFlightAndReleasesOnResolution(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{ + {ID: "winner", Port: 5001, SessionID: "session-1"}, + {ID: "loser", Port: 5002, SessionID: "session-1"}, + } + // Long enough per-attempt timeout to tolerate the delay below, so the + // loser's stop eventually succeeds instead of failing on a client-side + // timeout -- this test is about the success/resolution path, not the + // joint-failure path TestStandaloneExecutorRecoverInstancesJointStopFailureRetainsSessionUnstoppable + // already covers. + control.deleteDelay["loser"] = 30 * time.Millisecond + exec := control.executor(t) + exec.SetRecoveryRetryConfig(200*time.Millisecond, 0) + guard := NewRecoveryGuard() + guard.AcquireOrObserve("session-1") + exec.SetUnstoppableSessionRecorder(guard) + + records := []*models.ExecutorRunning{{SessionID: "session-1", AgentExecutionID: "winner"}} + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + recovered, err := exec.RecoverInstances(ctx, records) + if err != nil { + t.Fatalf("RecoverInstances: %v", err) + } + if len(recovered) != 0 { + t.Fatalf("recovered = %+v, want none: the loser stop was still unresolved at the deadline", recovered) + } + + // Simulate the bulk release a Start() pass performs once every + // reconstructed session's outcome is published: it must not release + // this session's guard yet, since its stop is still resolving. + guard.ReleaseAllExceptRetained() + if err := guard.CheckLaunchAllowed("session-1"); !errors.Is(err, ErrSessionRecoveryGuarded) { + t.Fatalf("guard state right after the deadline = %v, want still held (ErrSessionRecoveryGuarded)", err) + } + + pollDeadline := time.Now().Add(2 * time.Second) + for guard.CheckLaunchAllowed("session-1") != nil { + if time.Now().After(pollDeadline) { + t.Fatal("timed out waiting for the background drain to release the guard") + } + time.Sleep(5 * time.Millisecond) + } + + control.mu.Lock() + deleted := append([]string(nil), control.deleted...) + control.mu.Unlock() + if len(deleted) != 1 || deleted[0] != "loser" { + t.Fatalf("deleted = %v, want only the loser stopped: the winner must be untouched when the loser eventually succeeds", deleted) + } +} + +// TestStandaloneExecutorRecoverInstancesOrphanStopFailureIsRecordedNotJoint +// pins that an orphan's (AC-EXECUTORS-SURVIVAL-002.6) exhausted stop retry +// has no session to affect: it is only logged, never routed through the +// unstoppable-session recorder. +func TestStandaloneExecutorRecoverInstancesOrphanStopFailureIsRecordedNotJoint(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{ + {ID: "orphan-instance", Port: 5002, SessionID: "session-orphan"}, + } + control.deleteFailures["orphan-instance"] = 999 + exec := control.executor(t) + exec.SetRecoveryRetryConfig(50*time.Millisecond, 0) + recorder := &fakeUnstoppableRecorder{} + exec.SetUnstoppableSessionRecorder(recorder) + + recovered, err := exec.RecoverInstances(context.Background(), nil) + if err != nil { + t.Fatalf("RecoverInstances: %v", err) + } + if len(recovered) != 0 { + t.Fatalf("recovered = %+v, want none", recovered) + } + + recorder.mu.Lock() + defer recorder.mu.Unlock() + if len(recorder.retained) != 0 { + t.Fatalf("retained = %v, want none: an orphan has no session to retain", recorder.retained) + } +} + func TestBuildStandaloneCreateInstanceRequestMapsEveryField(t *testing.T) { autoApprove := true req := &ExecutorCreateRequest{ diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone_turn_outcome.go b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone_turn_outcome.go new file mode 100644 index 0000000000..c60f3a24fd --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone_turn_outcome.go @@ -0,0 +1,54 @@ +package lifecycle + +import ( + "context" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" +) + +// fetchTurnOutcomeWithRetry retrieves instanceID's retained terminal turn +// outcome within the same bounded per-attempt timeout and retry count as +// listInstancesWithRetry/stopWithRetry (AC-EXECUTORS-SURVIVAL-004.5 requires +// the same bound as AC-EXECUTORS-SURVIVAL-002.13). Returns (nil, nil) when +// the read succeeded and nothing was retained -- ControlClient.GetTurnOutcome +// already distinguishes that from a failure, and this method preserves the +// distinction rather than treating it as an error to retry. +func (r *StandaloneExecutor) fetchTurnOutcomeWithRetry(ctx context.Context, instanceID string) (*agentctl.TurnOutcome, error) { + timeout := r.recoveryReadTimeout + if timeout <= 0 { + timeout = defaultRecoveryReadTimeout + } + retries := r.recoveryReadRetries + if retries < 0 { + retries = defaultRecoveryReadRetries + } + + var lastErr error + for attempt := 0; attempt <= retries; attempt++ { + attemptCtx, cancel := context.WithTimeout(ctx, timeout) + outcome, err := r.ctl.GetTurnOutcome(attemptCtx, instanceID) + cancel() + if err == nil { + return outcome, nil + } + lastErr = err + } + return nil, lastErr +} + +// ackTurnOutcome acknowledges instanceID's retained outcome by turn +// identifier, discarding it (AC-EXECUTORS-SURVIVAL-004.6). Bounded by a +// single attempt at the same per-attempt timeout as the read: the +// acknowledgement is idempotent and safe to retry (naming an identifier the +// control server no longer holds changes nothing), so a failed ack here is +// left for the next backend's re-application of the same outcome rather than +// retried in a loop that would delay this recovery pass. +func (r *StandaloneExecutor) ackTurnOutcome(ctx context.Context, instanceID string, turnID int64) error { + timeout := r.recoveryReadTimeout + if timeout <= 0 { + timeout = defaultRecoveryReadTimeout + } + attemptCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + return r.ctl.AckTurnOutcome(attemptCtx, instanceID, turnID) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone_turn_outcome_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone_turn_outcome_test.go new file mode 100644 index 0000000000..4a7c47bf2a --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone_turn_outcome_test.go @@ -0,0 +1,116 @@ +package lifecycle + +import ( + "context" + "testing" + + agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// TestFetchTurnOutcomeWithRetryReturnsNilWhenNothingRetained pins +// AC-EXECUTORS-SURVIVAL-004.5's "nothing retained" case: a successful read +// with no outcome returns (nil, nil), never an error. +func TestFetchTurnOutcomeWithRetryReturnsNilWhenNothingRetained(t *testing.T) { + control := newStandaloneControlServer(t, true) + exec := control.executor(t) + + outcome, err := exec.fetchTurnOutcomeWithRetry(context.Background(), "inst-1") + if err != nil { + t.Fatalf("fetchTurnOutcomeWithRetry() error = %v, want nil", err) + } + if outcome != nil { + t.Fatalf("fetchTurnOutcomeWithRetry() = %+v, want nil", outcome) + } + if control.turnOutcomeAttempts["inst-1"] != 1 { + t.Fatalf("attempts = %d, want 1 (no retry needed on success)", control.turnOutcomeAttempts["inst-1"]) + } +} + +// TestFetchTurnOutcomeWithRetryReturnsRetainedOutcome pins the "outcome +// present" case, including that the turn identifier and event round-trip +// intact. +func TestFetchTurnOutcomeWithRetryReturnsRetainedOutcome(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.turnOutcomes["inst-1"] = agentctlclient.TurnOutcome{ + TurnID: 42, + Event: streams.AgentEvent{Type: streams.EventTypeComplete, Text: "done"}, + } + exec := control.executor(t) + + outcome, err := exec.fetchTurnOutcomeWithRetry(context.Background(), "inst-1") + if err != nil { + t.Fatalf("fetchTurnOutcomeWithRetry() error = %v", err) + } + if outcome == nil { + t.Fatal("fetchTurnOutcomeWithRetry() = nil, want the retained outcome") + } + if outcome.TurnID != 42 || outcome.Event.Text != "done" { + t.Fatalf("outcome = %+v, want TurnID 42 and Event.Text %q", outcome, "done") + } +} + +// TestFetchTurnOutcomeWithRetryRetriesTransientFailure pins +// AC-EXECUTORS-SURVIVAL-004.5's bound: the read is retried the configured +// number of times before giving up. +func TestFetchTurnOutcomeWithRetryRetriesTransientFailure(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.turnOutcomeFailures["inst-1"] = 1 + control.turnOutcomes["inst-1"] = agentctlclient.TurnOutcome{ + TurnID: 7, + Event: streams.AgentEvent{Type: streams.EventTypeError}, + } + exec := control.executor(t) + exec.SetRecoveryRetryConfig(0, 2) + + outcome, err := exec.fetchTurnOutcomeWithRetry(context.Background(), "inst-1") + if err != nil { + t.Fatalf("fetchTurnOutcomeWithRetry() error = %v, want success after retry", err) + } + if outcome == nil || outcome.TurnID != 7 { + t.Fatalf("outcome = %+v, want TurnID 7", outcome) + } + if control.turnOutcomeAttempts["inst-1"] != 2 { + t.Fatalf("attempts = %d, want 2 (one failure, one success)", control.turnOutcomeAttempts["inst-1"]) + } +} + +// TestFetchTurnOutcomeWithRetryExhaustsConfiguredRetries pins the exact +// attempt count when every attempt fails: AC-EXECUTORS-SURVIVAL-004.5's +// read-failed case, which the recovery loop must treat as "not-re-tracked," +// never as "nothing retained." +func TestFetchTurnOutcomeWithRetryExhaustsConfiguredRetries(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.turnOutcomeFailures["inst-1"] = 100 + exec := control.executor(t) + exec.SetRecoveryRetryConfig(0, 2) + + outcome, err := exec.fetchTurnOutcomeWithRetry(context.Background(), "inst-1") + if err == nil { + t.Fatal("fetchTurnOutcomeWithRetry() error = nil, want an error after exhausting retries") + } + if outcome != nil { + t.Fatalf("outcome = %+v, want nil on failure", outcome) + } + if control.turnOutcomeAttempts["inst-1"] != 3 { + t.Fatalf("attempts = %d, want 3 (1 initial + 2 retries)", control.turnOutcomeAttempts["inst-1"]) + } +} + +// TestAckTurnOutcomeSendsInstanceAndTurnID pins the wire shape of the +// acknowledgement call. +func TestAckTurnOutcomeSendsInstanceAndTurnID(t *testing.T) { + control := newStandaloneControlServer(t, true) + exec := control.executor(t) + + if err := exec.ackTurnOutcome(context.Background(), "inst-1", 42); err != nil { + t.Fatalf("ackTurnOutcome() error = %v", err) + } + if len(control.ackedTurnOutcomes) != 1 { + t.Fatalf("acked calls = %d, want 1", len(control.ackedTurnOutcomes)) + } + got := control.ackedTurnOutcomes[0] + if got.instanceID != "inst-1" || got.turnID != 42 { + t.Fatalf("acked = %+v, want {inst-1 42}", got) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/liveness_inherited_scope.go b/apps/backend/internal/agent/runtime/lifecycle/liveness_inherited_scope.go new file mode 100644 index 0000000000..41da7b79b3 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/liveness_inherited_scope.go @@ -0,0 +1,44 @@ +package lifecycle + +// InheritedRecordScope names what this launch found at the recorded control +// endpoint. It governs only records this backend did NOT create during this +// process lifetime -- the ones inherited from an earlier launch -- because a +// record this backend created is judged against whichever server it is using, +// adopted or freshly started. +// +// The distinction matters because a fresh server's enumeration is not +// evidence about an instance it never launched. Judging an inherited record +// against it would report a still-running agent as gone. +type InheritedRecordScope int + +const ( + // InheritedRecordScopeNoServer is nothing answered at the recorded + // endpoint, or no endpoint was recorded. No control server holds any + // inherited instance, so the process-identifier probe applies and + // genuinely dead records are still repaired. This is the zero value: + // a launch that never attempted adoption is in exactly this position. + InheritedRecordScopeNoServer InheritedRecordScope = iota + + // InheritedRecordScopeForeignServer is a server answered at the recorded + // endpoint and was left running, which is every path where adoption did + // not complete against a reachable server. A surviving instance runs on + // a server this backend does not drive, so its absence from the one this + // backend does drive proves nothing and every inherited record is + // unknown. Classifying it dead here would repair or prune a record whose + // agent is still running. + InheritedRecordScopeForeignServer + + // InheritedRecordScopeAdopted is this backend adopted the recorded + // server, so its enumeration is authoritative for inherited records. + InheritedRecordScopeAdopted +) + +// SetInheritedRecordScope installs what this launch found at the recorded +// control endpoint, so liveness classification of records inherited from an +// earlier launch can tell "no server holds these" from "a server this +// backend does not drive might". Set once during startup wiring, before +// Start runs; the zero value (no server) is correct for a launch that never +// attempted adoption. +func (m *Manager) SetInheritedRecordScope(scope InheritedRecordScope) { + m.inheritedRecordScope = scope +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/liveness_inherited_scope_test.go b/apps/backend/internal/agent/runtime/lifecycle/liveness_inherited_scope_test.go new file mode 100644 index 0000000000..0cfce1e161 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/liveness_inherited_scope_test.go @@ -0,0 +1,82 @@ +package lifecycle + +import ( + "testing" + + "github.com/kandev/kandev/internal/agentruntime" + "github.com/kandev/kandev/internal/task/models" +) + +// TestInheritedRecordIsProbedWhenNothingAnsweredAtTheRecordedEndpoint is the +// case that made every inherited record permanently unrepairable. On a start +// where nothing answered at the recorded endpoint, no control server holds +// those instances at all, so the process-identifier probe is the right +// authority and a genuinely dead record must still be repaired. Judging the +// record against the enumeration of a server this launch just started +// answers Unknown forever, and Unknown means "leave it alone". +func TestInheritedRecordIsProbedWhenNothingAnsweredAtTheRecordedEndpoint(t *testing.T) { + mgr := newLivenessTestManager(t, newStandaloneControlServer(t, true)) + mgr.SetInheritedRecordScope(InheritedRecordScopeNoServer) + row := &models.ExecutorRunning{ + SessionID: "session-inherited-noserver", + Runtime: agentruntime.RuntimeStandalone, + LocalPID: spawnAndReapPID(t), + } + // Reachable and empty: this is the freshly started server's enumeration, + // which never launched the inherited instance and is therefore not + // evidence about it either way. + scope := &standaloneLivenessScope{reachable: true, liveBySession: map[string]struct{}{}} + + if got := mgr.classifyStandaloneLiveness(row, scope); got != models.ProcessLivenessDead { + t.Fatalf("classifyStandaloneLiveness = %v, want Dead via the process-identifier probe", got) + } +} + +// TestInheritedRecordIsUnknownWhenAForeignServerAnswered is the opposite +// direction and the reason the probe cannot simply always apply. A server +// answered at the recorded endpoint and was left running, so the instance +// may still be alive on it. Probing a recycled or reused local PID there +// could report the record dead and repair a record whose agent is still +// working. +func TestInheritedRecordIsUnknownWhenAForeignServerAnswered(t *testing.T) { + mgr := newLivenessTestManager(t, newStandaloneControlServer(t, true)) + mgr.SetInheritedRecordScope(InheritedRecordScopeForeignServer) + row := &models.ExecutorRunning{ + SessionID: "session-inherited-foreign", + Runtime: agentruntime.RuntimeStandalone, + LocalPID: spawnAndReapPID(t), + } + scope := &standaloneLivenessScope{reachable: true, liveBySession: map[string]struct{}{}} + + if got := mgr.classifyStandaloneLiveness(row, scope); got != models.ProcessLivenessUnknown { + t.Fatalf("classifyStandaloneLiveness = %v, want Unknown while a server this backend did not adopt is running", got) + } +} + +// TestRecordCreatedThisLifetimeIgnoresTheInheritedScope pins the split the +// rule turns on. A record this backend created is judged against whichever +// server it is using, adopted or freshly started, because that server is the +// one that launched the instance. Only records inherited from an earlier +// launch depend on what answered at the recorded endpoint. +func TestRecordCreatedThisLifetimeIgnoresTheInheritedScope(t *testing.T) { + for _, scopeKind := range []InheritedRecordScope{ + InheritedRecordScopeNoServer, + InheritedRecordScopeForeignServer, + InheritedRecordScopeAdopted, + } { + mgr := newLivenessTestManager(t, newStandaloneControlServer(t, true)) + mgr.SetInheritedRecordScope(scopeKind) + mgr.markSessionCreatedThisLifetime("session-own") + + alive := &models.ExecutorRunning{SessionID: "session-own", Runtime: agentruntime.RuntimeStandalone} + present := &standaloneLivenessScope{reachable: true, liveBySession: map[string]struct{}{"session-own": {}}} + if got := mgr.classifyStandaloneLiveness(alive, present); got != models.ProcessLivenessAlive { + t.Fatalf("scope %v: present own record = %v, want Alive", scopeKind, got) + } + + absent := &standaloneLivenessScope{reachable: true, liveBySession: map[string]struct{}{}} + if got := mgr.classifyStandaloneLiveness(alive, absent); got != models.ProcessLivenessDead { + t.Fatalf("scope %v: absent own record = %v, want Dead", scopeKind, got) + } + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/liveness_standalone.go b/apps/backend/internal/agent/runtime/lifecycle/liveness_standalone.go new file mode 100644 index 0000000000..3165198da8 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/liveness_standalone.go @@ -0,0 +1,174 @@ +package lifecycle + +import ( + "context" + + "github.com/kandev/kandev/internal/agent/executor" + "github.com/kandev/kandev/internal/task/models" +) + +// standaloneLivenessScope is a single adopted-server enumeration snapshot +// (design 02 "Persistence": "liveness for a standalone record is judged by +// the presence of that record's instance in the adopted server's +// enumeration"). Reachable is false when the enumeration attempt itself +// failed -- nothing answered -- as distinct from a successful enumeration +// that simply found no matching instance. Produced by +// StandaloneExecutor.newLivenessScope and Manager.NewStandaloneLivenessScope; +// consumed only by classifyStandaloneLiveness. Never memoized across calls: +// a caller that wants one enumeration reused across many rows (a +// reconciliation pass) must build it once and thread it through itself; any +// other caller must build a fresh one for each classification (design 02 +// "two kinds of caller, and only one of them is a pass"). +type standaloneLivenessScope struct { + reachable bool + liveBySession map[string]struct{} +} + +// newLivenessScope takes one enumeration of the adopted server's live +// instances, keyed by session ID for O(1) row lookups. Reuses the same +// bounded per-attempt timeout/retry shape as listInstancesWithRetry's other +// callers (AC-EXECUTORS-SURVIVAL-002.13). +func (r *StandaloneExecutor) newLivenessScope(ctx context.Context) *standaloneLivenessScope { + instances, err := r.listInstancesWithRetry(ctx) + if err != nil { + return &standaloneLivenessScope{reachable: false} + } + live := make(map[string]struct{}, len(instances)) + for _, inst := range instances { + if inst.SessionID != "" { + live[inst.SessionID] = struct{}{} + } + } + return &standaloneLivenessScope{reachable: true, liveBySession: live} +} + +// NewStandaloneLivenessScope takes one adopted-server enumeration for reuse +// across every row of a single reconciliation pass. Returns nil when no +// standalone backend is registered; classifyStandaloneLiveness treats a nil +// scope identically to an unreachable one (falls back to the +// process-identifier probe). +func (m *Manager) NewStandaloneLivenessScope(ctx context.Context) interface{} { + backend, err := m.executorRegistry.GetBackend(executor.NameStandalone) + if err != nil { + return (*standaloneLivenessScope)(nil) + } + standalone, ok := backend.(*StandaloneExecutor) + if !ok { + return (*standaloneLivenessScope)(nil) + } + return standalone.newLivenessScope(ctx) +} + +// classifyStandaloneLiveness implements design 02's "Persistence" liveness +// rule for a single row, given a (possibly nil/unreachable) enumeration +// scope: +// +// - non-standalone runtime: unchanged, delegates to RowProcessLiveness +// (Unknown -- never probed by a local process check). +// - agent survival capability disabled: unchanged, delegates to +// RowProcessLiveness -- an enumeration scope is never consulted when the +// capability that makes a standalone control server outlive this +// backend isn't itself enabled. +// - scope nil or unreachable ("nothing answered"): presence is +// undeterminable, so Unknown -- except where no control server answered +// at the recorded endpoint at all, which falls back to today's +// process-identifier probe so a genuinely dead row is still repaired on +// the common case of a first start with no survivor. +// - present in the enumeration, but this session's stop was still in +// flight when the enumeration was taken: Unknown, not Alive -- the +// snapshot predates the stop's actual completion. +// - present, no stop in flight: Alive. +// - absent, and this backend created the row during its own process +// lifetime: Dead -- this exact server is the only authority for it, so +// "not present" is determinate. +// - absent, and the row was inherited from an earlier launch while this +// backend adopted the recorded server: Unknown. +// +// A record inherited from an earlier launch is not judged against the +// enumeration at all unless this backend adopted the recorded server, since +// a server this backend started never launched that instance. What happens +// instead turns on what answered at the recorded endpoint: nothing answered +// means no server holds it, so the process-identifier probe applies and a +// genuinely dead record is still repaired; a server that answered and was +// left running means the instance may still be alive on a server this +// backend does not drive, so the record is Unknown and is left alone. +func (m *Manager) classifyStandaloneLiveness(row *models.ExecutorRunning, scope *standaloneLivenessScope) models.ProcessLiveness { + if row == nil { + return models.ProcessLivenessUnknown + } + if !isLocalRuntime(row.Runtime) { + return RowProcessLiveness(row) + } + if !m.agentSurvivalEnabled { + return RowProcessLiveness(row) + } + if scope == nil || !scope.reachable { + // AC-EXECUTORS-SURVIVAL-003.6: presence could not be determined, so + // this is unknown rather than live or dead. The process-identifier + // probe cannot stand in for it here: a standalone row carries the + // SHARED control server's identifier, so with a server still running + // it answers live for an instance that is already gone -- reporting a + // record live solely because the shared control server is running, + // which that criterion forbids, and leaving the row to escape repair + // indefinitely. The probe is only evidence where no control server + // holds these instances at all, which is exactly the case the + // criterion names: nothing answered at the recorded endpoint, or no + // endpoint was recorded. + if m.inheritedRecordScope == InheritedRecordScopeNoServer { + return RowProcessLiveness(row) + } + return models.ProcessLivenessUnknown + } + sessionID := row.SessionID + createdHere := m.wasCreatedThisLifetime(sessionID) + if !createdHere && m.inheritedRecordScope != InheritedRecordScopeAdopted { + // An inherited record is judged only against a server this backend + // adopted. A freshly started server never launched this instance, so + // its enumeration is not evidence either way and is not consulted. + if m.inheritedRecordScope == InheritedRecordScopeNoServer { + return RowProcessLiveness(row) + } + return models.ProcessLivenessUnknown + } + if _, present := scope.liveBySession[sessionID]; present { + if m.recoveryGuard.IsStopInFlight(sessionID) { + return models.ProcessLivenessUnknown + } + return models.ProcessLivenessAlive + } + if createdHere { + return models.ProcessLivenessDead + } + return models.ProcessLivenessUnknown +} + +// RowLivenessScoped classifies row's liveness using scope (from +// NewStandaloneLivenessScope), reused across every row of one reconciliation +// pass. scope must be the interface{} value NewStandaloneLivenessScope +// returned (or nil); any other type is treated as unreachable. +func (m *Manager) RowLivenessScoped(row *models.ExecutorRunning, scope interface{}) models.ProcessLiveness { + s, _ := scope.(*standaloneLivenessScope) + return m.classifyStandaloneLiveness(row, s) +} + +// RowLiveness classifies row's liveness for a caller outside a reconciliation +// pass (e.g. the single-session idle reclaim path): it always takes its own +// fresh enumeration rather than reading one a pass cached (design 02 "two +// kinds of caller"), since such a caller can fire at any moment and a cached +// answer would age without bound. +// +// AC-EXECUTORS-SURVIVAL-003.6: such a caller firing before Start's recovery +// pass has finished for this process's lifetime must answer Unknown +// immediately -- neither enumerate nor wait -- since re-tracking has not yet +// reached an outcome for every record and a live enumeration taken now could +// race work recovery itself has not finished doing. +func (m *Manager) RowLiveness(row *models.ExecutorRunning) models.ProcessLiveness { + if row == nil || !isLocalRuntime(row.Runtime) { + return RowProcessLiveness(row) + } + if !m.recoveryComplete.Load() { + return models.ProcessLivenessUnknown + } + scope, _ := m.NewStandaloneLivenessScope(context.Background()).(*standaloneLivenessScope) + return m.classifyStandaloneLiveness(row, scope) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/liveness_standalone_test.go b/apps/backend/internal/agent/runtime/lifecycle/liveness_standalone_test.go new file mode 100644 index 0000000000..8b24137b60 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/liveness_standalone_test.go @@ -0,0 +1,264 @@ +package lifecycle + +import ( + "context" + "os" + "testing" + "time" + + agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/agentruntime" + "github.com/kandev/kandev/internal/task/models" +) + +// newLivenessTestManager builds a Manager whose registered standalone +// backend talks to a real fake control server, so enumeration-based liveness +// classification exercises the actual HTTP round trip. The capability is +// enabled by default since these tests exercise the enumeration-scope +// classification path; a test of the disabled path overrides it explicitly. +func newLivenessTestManager(t *testing.T, control *standaloneControlServer) *Manager { + t.Helper() + log := newTestLogger() + execRegistry := NewExecutorRegistry(log) + execRegistry.Register(control.executor(t)) + mgr := NewManager( + newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, + ExecutorFallbackWarn, "", log, + ) + mgr.SetAgentSurvivalEnabled(true) + cleanupManagerStopCh(t, mgr) + return mgr +} + +func TestStandaloneExecutorNewLivenessScopeReachablePopulatesSessions(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{ + {ID: "instance-1", SessionID: "session-1"}, + {ID: "instance-2", SessionID: "session-2"}, + } + exec := control.executor(t) + + scope := exec.newLivenessScope(context.Background()) + if !scope.reachable { + t.Fatal("expected a successful enumeration to report reachable") + } + if _, ok := scope.liveBySession["session-1"]; !ok { + t.Fatal("expected session-1 to be present in the enumeration") + } + if _, ok := scope.liveBySession["session-2"]; !ok { + t.Fatal("expected session-2 to be present in the enumeration") + } + if len(scope.liveBySession) != 2 { + t.Fatalf("liveBySession = %+v, want exactly 2 entries", scope.liveBySession) + } +} + +func TestStandaloneExecutorNewLivenessScopeUnreachableWhenEnumerationFails(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstancesErr = true + exec := control.executor(t) + exec.SetRecoveryRetryConfig(10*time.Millisecond, 0) + + scope := exec.newLivenessScope(context.Background()) + if scope.reachable { + t.Fatal("expected an enumeration failure to report unreachable, not reachable-with-nothing") + } +} + +func TestManagerClassifyStandaloneLivenessNonStandaloneRuntimeUnchanged(t *testing.T) { + mgr := newLivenessTestManager(t, newStandaloneControlServer(t, true)) + row := &models.ExecutorRunning{SessionID: "session-1", Runtime: agentruntime.RuntimeSSH} + scope := &standaloneLivenessScope{reachable: true, liveBySession: map[string]struct{}{"session-1": {}}} + + got := mgr.classifyStandaloneLiveness(row, scope) + if got != models.ProcessLivenessUnknown { + t.Fatalf("classifyStandaloneLiveness for a non-standalone row = %v, want Unknown regardless of scope", got) + } +} + +func TestManagerClassifyStandaloneLivenessNothingAnsweredFallsBackToProcessProbe(t *testing.T) { + mgr := newLivenessTestManager(t, newStandaloneControlServer(t, true)) + aliveRow := &models.ExecutorRunning{ + SessionID: "session-alive", Runtime: agentruntime.RuntimeStandalone, LocalPID: os.Getpid(), + } + deadRow := &models.ExecutorRunning{ + SessionID: "session-dead", Runtime: agentruntime.RuntimeStandalone, LocalPID: spawnAndReapPID(t), + } + + for _, scope := range []*standaloneLivenessScope{nil, {reachable: false}} { + if got := mgr.classifyStandaloneLiveness(aliveRow, scope); got != models.ProcessLivenessAlive { + t.Fatalf("scope=%+v: classifyStandaloneLiveness(aliveRow) = %v, want Alive via process-identifier fallback", scope, got) + } + if got := mgr.classifyStandaloneLiveness(deadRow, scope); got != models.ProcessLivenessDead { + t.Fatalf("scope=%+v: classifyStandaloneLiveness(deadRow) = %v, want Dead via process-identifier fallback", scope, got) + } + } +} + +func TestManagerClassifyStandaloneLivenessPresentNoStopInFlightIsAlive(t *testing.T) { + mgr := newLivenessTestManager(t, newStandaloneControlServer(t, true)) + mgr.SetInheritedRecordScope(InheritedRecordScopeAdopted) + row := &models.ExecutorRunning{SessionID: "session-present", Runtime: agentruntime.RuntimeStandalone} + scope := &standaloneLivenessScope{reachable: true, liveBySession: map[string]struct{}{"session-present": {}}} + + got := mgr.classifyStandaloneLiveness(row, scope) + if got != models.ProcessLivenessAlive { + t.Fatalf("classifyStandaloneLiveness = %v, want Alive for a present instance with no stop in flight", got) + } +} + +func TestManagerClassifyStandaloneLivenessPresentWithStopInFlightIsUnknown(t *testing.T) { + mgr := newLivenessTestManager(t, newStandaloneControlServer(t, true)) + mgr.SetInheritedRecordScope(InheritedRecordScopeAdopted) + row := &models.ExecutorRunning{SessionID: "session-stopping", Runtime: agentruntime.RuntimeStandalone} + scope := &standaloneLivenessScope{reachable: true, liveBySession: map[string]struct{}{"session-stopping": {}}} + mgr.recoveryGuard.AcquireOrObserve("session-stopping") + mgr.recoveryGuard.MarkStopInFlight("session-stopping") + + got := mgr.classifyStandaloneLiveness(row, scope) + if got != models.ProcessLivenessUnknown { + t.Fatalf("classifyStandaloneLiveness = %v, want Unknown when the row's stop is still in flight", got) + } +} + +func TestManagerClassifyStandaloneLivenessAbsentOwnRecordIsDead(t *testing.T) { + mgr := newLivenessTestManager(t, newStandaloneControlServer(t, true)) + row := &models.ExecutorRunning{SessionID: "session-own", Runtime: agentruntime.RuntimeStandalone} + scope := &standaloneLivenessScope{reachable: true, liveBySession: map[string]struct{}{}} + mgr.markSessionCreatedThisLifetime("session-own") + + got := mgr.classifyStandaloneLiveness(row, scope) + if got != models.ProcessLivenessDead { + t.Fatalf("classifyStandaloneLiveness = %v, want Dead for an own record absent from the enumeration", got) + } +} + +// TestManagerClassifyStandaloneLivenessCapabilityDisabledFallsBackToProcessProbe +// pins Review round 5 finding 3: when agentSurvivalEnabled is false, an +// enumeration scope must never be consulted -- classification always falls +// back to the process-identifier probe, exactly as it did before the +// standalone control server's enumeration-based liveness existed. Without +// this gate, an inherited row absent from a reachable enumeration would +// classify Unknown even though its process is provably dead. +func TestManagerClassifyStandaloneLivenessCapabilityDisabledFallsBackToProcessProbe(t *testing.T) { + mgr := newLivenessTestManager(t, newStandaloneControlServer(t, true)) + mgr.SetAgentSurvivalEnabled(false) + row := &models.ExecutorRunning{ + SessionID: "session-inherited-disabled", Runtime: agentruntime.RuntimeStandalone, LocalPID: spawnAndReapPID(t), + } + // Reachable and would report the row absent -- if consulted, this would + // wrongly yield Unknown (inherited-absent) instead of Dead. + scope := &standaloneLivenessScope{reachable: true, liveBySession: map[string]struct{}{}} + + got := mgr.classifyStandaloneLiveness(row, scope) + if got != models.ProcessLivenessDead { + t.Fatalf("classifyStandaloneLiveness with capability disabled = %v, want Dead via process-identifier fallback", got) + } +} + +func TestManagerClassifyStandaloneLivenessAbsentInheritedRecordIsUnknown(t *testing.T) { + mgr := newLivenessTestManager(t, newStandaloneControlServer(t, true)) + // This backend adopted the recorded server, so its enumeration is + // authoritative for a record an earlier launch left behind. + mgr.SetInheritedRecordScope(InheritedRecordScopeAdopted) + row := &models.ExecutorRunning{SessionID: "session-inherited", Runtime: agentruntime.RuntimeStandalone} + scope := &standaloneLivenessScope{reachable: true, liveBySession: map[string]struct{}{}} + // Deliberately NOT marked via markSessionCreatedThisLifetime: this is an + // inherited row this backend never created. + + got := mgr.classifyStandaloneLiveness(row, scope) + if got != models.ProcessLivenessUnknown { + t.Fatalf("classifyStandaloneLiveness = %v, want Unknown for an inherited record absent from the enumeration", got) + } +} + +func TestManagerRowLivenessTakesFreshEnumerationEachCall(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{{ID: "instance-1", SessionID: "session-1"}} + mgr := newLivenessTestManager(t, control) + // A caller outside Start's recovery pass only enumerates once recovery + // has completed for this process's lifetime (AC-EXECUTORS-SURVIVAL-003.6). + mgr.recoveryComplete.Store(true) + row := &models.ExecutorRunning{SessionID: "session-1", Runtime: agentruntime.RuntimeStandalone} + + mgr.RowLiveness(row) + mgr.RowLiveness(row) + + control.mu.Lock() + defer control.mu.Unlock() + if control.listInstancesAttempts != 2 { + t.Fatalf("listInstances attempts = %d, want exactly 2 (one fresh enumeration per out-of-pass call, never cached)", control.listInstancesAttempts) + } +} + +// TestManagerRowLivenessReturnsUnknownBeforeRecoveryCompletes pins Review +// round 1 finding 8 (AC-EXECUTORS-SURVIVAL-003.6): a caller outside a +// reconciliation pass (e.g. idle reclaim) firing before Start's recovery +// pass has finished for this process's lifetime must get Unknown immediately +// -- neither enumerate nor wait -- rather than racing a live enumeration +// against work recovery itself has not finished doing. +func TestManagerRowLivenessReturnsUnknownBeforeRecoveryCompletes(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{{ID: "instance-1", SessionID: "session-1"}} + mgr := newLivenessTestManager(t, control) + row := &models.ExecutorRunning{SessionID: "session-1", Runtime: agentruntime.RuntimeStandalone} + + got := mgr.RowLiveness(row) + if got != models.ProcessLivenessUnknown { + t.Fatalf("RowLiveness before recovery completes = %v, want Unknown", got) + } + + control.mu.Lock() + defer control.mu.Unlock() + if control.listInstancesAttempts != 0 { + t.Fatalf("listInstances attempts = %d, want 0 (must not enumerate before recovery completes)", control.listInstancesAttempts) + } +} + +func TestManagerRowLivenessScopedReusesSuppliedScope(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{{ID: "instance-1", SessionID: "session-1"}} + mgr := newLivenessTestManager(t, control) + rowA := &models.ExecutorRunning{SessionID: "session-1", Runtime: agentruntime.RuntimeStandalone} + rowB := &models.ExecutorRunning{SessionID: "session-2", Runtime: agentruntime.RuntimeStandalone} + + scope := mgr.NewStandaloneLivenessScope(context.Background()) + mgr.RowLivenessScoped(rowA, scope) + mgr.RowLivenessScoped(rowB, scope) + + control.mu.Lock() + defer control.mu.Unlock() + if control.listInstancesAttempts != 1 { + t.Fatalf("listInstances attempts = %d, want exactly 1 -- one enumeration reused across every row of the pass", control.listInstancesAttempts) + } +} + +// TestManagerClassifyStandaloneLivenessEnumerationFailedAgainstAServerIsUnknown +// pins the half of AC-EXECUTORS-SURVIVAL-003.6 that separates "nothing +// answered at the recorded endpoint" from "a server answered but could not be +// enumerated". Both reach classification as an unreachable scope, but only the +// former licenses the process-identifier probe: a standalone row carries the +// SHARED control server's identifier, so with a server still running the probe +// answers Alive for an instance that may already be gone -- reporting a record +// live solely because the shared control server is running, which that +// criterion forbids, and leaving the row to escape repair indefinitely. +func TestManagerClassifyStandaloneLivenessEnumerationFailedAgainstAServerIsUnknown(t *testing.T) { + // LocalPID is this process, which is certainly alive, so the + // process-identifier probe would answer Alive if it were consulted. + row := &models.ExecutorRunning{ + SessionID: "session-unknown", Runtime: agentruntime.RuntimeStandalone, LocalPID: os.Getpid(), + } + + for _, inherited := range []InheritedRecordScope{InheritedRecordScopeAdopted, InheritedRecordScopeForeignServer} { + for _, scope := range []*standaloneLivenessScope{nil, {reachable: false}} { + mgr := newLivenessTestManager(t, newStandaloneControlServer(t, true)) + mgr.SetInheritedRecordScope(inherited) + + got := mgr.classifyStandaloneLiveness(row, scope) + if got != models.ProcessLivenessUnknown { + t.Fatalf("inherited=%v scope=%+v: classifyStandaloneLiveness = %v, want Unknown when a server answered but its enumeration did not", + inherited, scope, got) + } + } + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager.go b/apps/backend/internal/agent/runtime/lifecycle/manager.go index 18277ee111..e0aeb6f295 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager.go @@ -89,6 +89,82 @@ type Manager struct { // permissions, so they get different checks. sessionExecCheck func(ctx context.Context, sessionID string) error + // recoveryGuard is the in-memory per-session acquire-or-observe map of + // AC-EXECUTORS-SURVIVAL-002.8, taken over the live standalone + // recovery-inventory records at startup step 3 before any control server + // is contacted, and consulted by Launch to refuse a concurrent request + // for a session that might still be re-tracking. Always non-nil. + recoveryGuard *RecoveryGuard + + // retrackedSessionsMu guards retrackedSessions. + retrackedSessionsMu sync.RWMutex + + // retrackedSessions is the set of session IDs this backend successfully + // re-tracked during its most recent Start() recovery pass + // (AC-EXECUTORS-SURVIVAL-003.1). Reset at the start of each Start() call, + // then populated once per recovered execution, right after it lands in + // executionStore -- never for an instance this pass explicitly refused + // to re-track (unreconstructable agent identity, unretrievable turn + // status). Queried by the orchestrator's startup reconciliation via + // WasSessionRetracked so a session whose agent actually survived the + // restart skips "backend died mid-turn" cleanup + // (AC-EXECUTORS-SURVIVAL-003.2/.3/.4/.5). Always non-nil after + // construction. + retrackedSessions map[string]struct{} + + // standaloneOwnSessionsMu guards standaloneOwnSessions. + standaloneOwnSessionsMu sync.RWMutex + + // standaloneOwnSessions is the set of session IDs whose executors_running + // row this backend itself created via Launch during this process's + // lifetime -- never populated by the recovery path (which adds directly + // to executionStore and marks retrackedSessions instead). Consulted by + // the standalone liveness classifier's own-record-vs-inherited-record + // carve-out (design 02 "Persistence": "A server this backend STARTED is + // not a server it adopted" -- a row this backend created is checkable + // against whichever control server this backend is currently using, + // adopted or freshly started, because that server is the only authority + // that can answer for it; an inherited row that server has never heard of + // classifies unknown instead of dead). Unlike retrackedSessions, this is + // NEVER reset by Start() -- it must accumulate for the whole process + // lifetime, not just one recovery pass. Always non-nil after + // construction. + standaloneOwnSessions map[string]struct{} + + // passthroughLookup reads a session's durable passthrough mode + // (TaskSession.IsPassthrough) for AC-EXECUTORS-SURVIVAL-005.3's startup + // guard exclusion. Nil = every live standalone session is guarded (the + // safe default: guarding a passthrough session by mistake only costs one + // refused launch, never excluding a real candidate). See + // SetPassthroughLookup. + passthroughLookup PassthroughLookup + + // recoveryDeadline and recoveryDeadlineStart implement the + // AC-EXECUTORS-SURVIVAL-003.7 single bound covering this startup pass's + // adoption+enumeration+reconstruction work. recoveryDeadlineStart is the + // instant this launch first contacted a recorded control endpoint (zero + // when it never did -- Start then falls back to its own invocation + // time); recoveryDeadline is the configured duration from that instant. + // A zero recoveryDeadline falls back to the AC's own 30s default. See + // SetRecoveryDeadline / SetRecoveryDeadlineStart. + recoveryDeadline time.Duration + recoveryDeadlineStart time.Time + + // agentSurvivalEnabled mirrors the features.agentSurvival runtime flag + // (config.Config.Features.AgentSurvival). When true and a stop's reason is + // StopReasonBackendShutdown, StopAgentWithReason takes the survivable-detach + // branch instead of the terminating one (design 01/02 kill-path #4). Set + // once during startup wiring via SetAgentSurvivalEnabled; false (today's + // unconditional terminating stop) is the correct zero value. + agentSurvivalEnabled bool + + // inheritedRecordScope records what this launch found at the recorded + // control endpoint, so classifyStandaloneLiveness can judge records + // inherited from an earlier launch correctly. Set once during startup + // wiring via SetInheritedRecordScope; the zero value (no server + // answered) is correct for a launch that never attempted adoption. + inheritedRecordScope InheritedRecordScope + // environmentAccessCheck is the environment-keyed sibling of // sessionAccessCheck, used by the terminal environment-shell route which // resolves executions by environment ID. Nil = no scoping. @@ -130,6 +206,15 @@ type Manager struct { // confusing errors against children already being stopped. shuttingDown atomic.Bool + // recoveryComplete is flipped true once Start's synchronous recovery + // pass (adoption, enumeration, and per-instance reconstruction) has + // finished for this process's lifetime. AC-EXECUTORS-SURVIVAL-003.6 + // requires a caller outside that pass (e.g. RowLiveness, the idle + // reclaim path) to answer Unknown without enumerating while recovery is + // still in flight, rather than racing a live enumeration against work + // recovery itself has not finished doing. + recoveryComplete atomic.Bool + // pollAggregator routes hub session-mode events to agentctl. See // manager_subscription.go. pollAggregator *workspacePollAggregator @@ -320,6 +405,9 @@ func NewManager( stopCh: stopCh, skillDeployer: NoopSkillDeployer(), remediateNpxCache: routingerr.RemediateNpxCache, + recoveryGuard: NewRecoveryGuard(), + retrackedSessions: make(map[string]struct{}), + standaloneOwnSessions: make(map[string]struct{}), } // Initialize stream manager with callbacks that delegate to manager methods // mcpHandler will be set later via SetMCPHandler. @@ -486,6 +574,123 @@ func (m *Manager) CheckSessionExecAccess(ctx context.Context, sessionID string) return m.sessionExecCheck(ctx, sessionID) } +// SetPassthroughLookup installs the durable passthrough-mode read used at +// startup step 3 to exclude passthrough sessions from the recovery guard +// (AC-EXECUTORS-SURVIVAL-005.3). Must read TaskSession.IsPassthrough from the +// durable store, never from in-memory execution state -- that state is empty +// at this point in startup, which would silently guard every passthrough +// session instead of excluding it. Set once during startup wiring, before +// Start runs. +func (m *Manager) SetPassthroughLookup(lookup PassthroughLookup) { + m.passthroughLookup = lookup +} + +// SetRecoveryGuard installs a pre-populated recovery guard, replacing the +// empty one NewManager created. Backend startup composition uses this to +// hand Start the same guard TakeStartupRecoveryGuards already took sessions +// on before any control server was contacted (AC-EXECUTORS-SURVIVAL-002.8) -- +// something that must happen ahead of an adoption attempt this Manager +// doesn't yet exist to perform itself. Start's own guard-taking is still +// safe to run afterward: AcquireOrObserve treats an already-guarded session +// as observed, not re-acquired. A nil guard is ignored so a caller with +// nothing pre-taken leaves the Manager's own default in place. +func (m *Manager) SetRecoveryGuard(guard *RecoveryGuard) { + if guard == nil { + return + } + m.recoveryGuard = guard +} + +// SetRecoveryDeadline installs the configured AC-EXECUTORS-SURVIVAL-003.7 +// recovery deadline duration. Zero (unset) falls back to the AC's own 30s +// default at Start. +func (m *Manager) SetRecoveryDeadline(d time.Duration) { + m.recoveryDeadline = d +} + +// SetRecoveryDeadlineStart installs the AC-EXECUTORS-SURVIVAL-003.7 recovery +// deadline's clock start: the instant this launch first contacted a recorded +// control endpoint. A zero value (no adoption attempted, or none recorded) +// falls back to Start's own invocation time. Set once during startup wiring, +// before Start runs. +func (m *Manager) SetRecoveryDeadlineStart(t time.Time) { + m.recoveryDeadlineStart = t +} + +// SetAgentSurvivalEnabled installs the features.agentSurvival capability +// state that StopAgentWithReason reads to decide between a survivable detach +// and today's terminating stop on backend shutdown. Set once during startup +// wiring, before Start runs. +func (m *Manager) SetAgentSurvivalEnabled(enabled bool) { + m.agentSurvivalEnabled = enabled +} + +// RecoveryGuard exposes the in-memory recovery guard so it can be wired into +// an ExecutorBackend that supports AC-EXECUTORS-SURVIVAL-002.16 (currently +// StandaloneExecutor, via SetUnstoppableSessionRecorder). Always non-nil. +func (m *Manager) RecoveryGuard() *RecoveryGuard { + return m.recoveryGuard +} + +// WasSessionRetracked reports whether sessionID was successfully re-tracked +// during this backend's most recent Start() recovery pass +// (AC-EXECUTORS-SURVIVAL-003.1). Safe for concurrent use. +func (m *Manager) WasSessionRetracked(sessionID string) bool { + if sessionID == "" { + return false + } + m.retrackedSessionsMu.RLock() + defer m.retrackedSessionsMu.RUnlock() + _, ok := m.retrackedSessions[sessionID] + return ok +} + +// markSessionRetracked records sessionID as re-tracked for the current +// Start() pass. See retrackedSessions. +func (m *Manager) markSessionRetracked(sessionID string) { + if sessionID == "" { + return + } + m.retrackedSessionsMu.Lock() + m.retrackedSessions[sessionID] = struct{}{} + m.retrackedSessionsMu.Unlock() +} + +// resetRetrackedSessions clears the re-tracked set at the start of a new +// Start() pass. +func (m *Manager) resetRetrackedSessions() { + m.retrackedSessionsMu.Lock() + m.retrackedSessions = make(map[string]struct{}) + m.retrackedSessionsMu.Unlock() +} + +// wasCreatedThisLifetime reports whether sessionID's executors_running row +// was created by THIS backend process via Launch, as opposed to inherited +// from an earlier launch and re-tracked at startup. See +// standaloneOwnSessions. Safe for concurrent use. +func (m *Manager) wasCreatedThisLifetime(sessionID string) bool { + if sessionID == "" { + return false + } + m.standaloneOwnSessionsMu.RLock() + defer m.standaloneOwnSessionsMu.RUnlock() + _, ok := m.standaloneOwnSessions[sessionID] + return ok +} + +// markSessionCreatedThisLifetime records sessionID as created by this +// backend process. See standaloneOwnSessions. Never cleared: unlike +// retrackedSessions this must survive for the whole process lifetime, not +// just one Start() pass. +func (m *Manager) markSessionCreatedThisLifetime(sessionID string) { + if sessionID == "" { + return + } + m.standaloneOwnSessionsMu.Lock() + m.standaloneOwnSessions[sessionID] = struct{}{} + m.standaloneOwnSessionsMu.Unlock() +} + // SetAttachmentReader wires the backend attachment reader used by prompt // dispatch. Claimed descriptors are streamed into the active agentctl // session immediately before an ACP prompt is sent. diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_events.go b/apps/backend/internal/agent/runtime/lifecycle/manager_events.go index 31c965b24a..7096205329 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_events.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_events.go @@ -278,7 +278,13 @@ func (m *Manager) claimPromptCompletion( claim.locked = true claimed := false err := m.executionStore.WithLock(execution.ID, func(current *AgentExecution) { - if current != execution || current.promptGeneration != event.PromptGeneration { + if current != execution { + return + } + if current.recoveredPromptGenerationPending.CompareAndSwap(true, false) { + current.promptGeneration = event.PromptGeneration + } + if current.promptGeneration != event.PromptGeneration { return } if current.promptCompletionGeneration == event.PromptGeneration { @@ -853,6 +859,15 @@ func (m *Manager) handleAgentEventWithAttempt( attemptID string, ) { event.AttemptID = attemptID + // A terminal event that was already applied from a retained turn outcome + // can be redelivered when the live stream attaches. Drop that exact event + // instead of applying the completion a second time. + if execution.isRecoveryDuplicateEvent(&event) { + m.logger.Debug("dropping live event: already applied via retained turn outcome", + zap.String("execution_id", execution.ID), + zap.Int64("control_turn_id", event.ControlTurnID)) + return + } if m.handleMCPAttachmentEvent(execution, &event, attemptID) { return } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go index e5efa774c4..7b9865ea73 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go @@ -1013,7 +1013,7 @@ func (m *Manager) prepareExecutionCreateRequest( PreviousExecutionID: info.AgentExecutionID, AuthToken: authToken, BootstrapNonce: m.revealRuntimeSecret(ctx, info.Metadata, MetadataKeyBootstrapNonceSecret), - AgentctlStartupConfig: m.agentctlStartupConfig, + AgentctlStartupConfig: agentctlStartupConfigForExecutor(m.agentctlStartupConfig, info.ExecutorType), RemoteContributions: remoteContributions, ContributionDestinations: contributionDestinations, ManagedRuntimeVersion: managedRuntimeVersion, diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go index 90951d4b65..4aee691234 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go @@ -970,6 +970,23 @@ func (m *Manager) StopAgentWithReason(ctx context.Context, executionID string, r // a potentially live runtime as idle. RemoveExecution releases the lease on // the successful path. + // AC-EXECUTORS-SURVIVAL kill-path #4 (design 01 "Kill paths that must + // change together", design 02 "Shutdown"): a graceful backend shutdown + // must not terminate the instance when the capability is enabled. Every + // other stop reason, including force, keeps the terminating path below. + // The capability covers only the standalone (worktree/local) runtime -- + // every other runtime's StopAllAgents call must still terminate normally + // even while the capability is globally enabled for the installation. + // A passthrough session is excluded too (AC-EXECUTORS-SURVIVAL-005.3, + // design 02 "Passthrough scope"): its agent runs on a terminal this + // backend process owns, so it dies with the backend regardless, and + // detaching would leave an executors_running row claiming a live agent + // with no agent.stopped published. See isPassthroughExecution. + if m.agentSurvivalEnabled && reason == StopReasonBackendShutdown && + execution.RuntimeName == executor.NameStandalone && !isPassthroughExecution(execution) { + return m.detachAgentExecution(executionID, execution) + } + m.logger.Info("stopping agent", zap.String("execution_id", executionID), zap.String("reason", reason), @@ -1025,6 +1042,52 @@ func (m *Manager) StopAgentWithReason(ctx context.Context, executionID string, r return nil } +// detachAgentExecution implements the AC-EXECUTORS-SURVIVAL survivable-detach +// branch of StopAgentWithReason (design 01/02 kill-path #4): when the +// agent-survival capability is enabled and the stop reason is graceful +// backend shutdown, this backend releases its own hold on the execution -- +// its agentctl stream subscription and in-memory tracking -- without +// stopping the agentctl-side instance or calling the runtime backend's +// StopInstance. The instance is left running, unowned by this backend, so +// the adoption path (Layer 6.1) or a fresh recovery pass on the next startup +// can find and re-track it. +// +// Deliberately does NOT: call client.Stop, call stopAgentViaBackend, set +// execution.Status to stopped, or publish events.AgentStopped. The execution +// is not stopped -- publishing that event would run the orchestrator's +// handleAgentStopped, which persists a terminal task-session state for a +// session whose agent is still running, corrupting the very state recovery +// depends on. The executors_running row is intentionally left untouched so +// AC-EXECUTORS-SURVIVAL-002's recovery-inventory read still finds it live. +// +// Also deliberately does NOT call client.Close(): closing the client tears +// down the agentctl updates stream, which makes any in-flight SendPrompt's +// stream-reader goroutine observe a disconnect and report it through the +// same error path a genuine agent crash uses (handleInitialPromptFailure / +// handleErrorEvent) -- persisting the executors_running row as failed out +// from under the very read this function's contract promises to leave +// untouched, and racing RemoveExecution below to do it. Simply dropping the +// local reference lets this backend's process exit (moments later, in the +// same shutdown) reclaim the socket without disturbing the still-running +// agentctl instance or its live stream. +func (m *Manager) detachAgentExecution(executionID string, execution *AgentExecution) error { + execution.agentctlLifecycleMu.Lock() + execution.detachAgentctlClient() + execution.agentctlLifecycleMu.Unlock() + + execution.EndSessionSpan() + m.RemoveExecution(executionID) + m.clearRemoteStatus(execution.SessionID) + + m.logger.Info("detached agent execution for survivable backend shutdown; instance left running", + zap.String("execution_id", executionID), + zap.String("session_id", execution.SessionID), + zap.String("task_id", execution.TaskID), + zap.Stringer("runtime", execution.RuntimeName)) + + return nil +} + func (m *Manager) stopExecutionAgentctl( ctx context.Context, executionID string, diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index 3b0e4a408f..300d21e44b 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -1053,7 +1053,7 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st McpProfile: reqWithWorktree.McpProfile, AuthToken: launchAuthToken, BootstrapNonce: m.revealRuntimeSecret(ctx, metadata, MetadataKeyBootstrapNonceSecret), - AgentctlStartupConfig: m.agentctlStartupConfig, + AgentctlStartupConfig: agentctlStartupConfigForExecutor(m.agentctlStartupConfig, reqWithWorktree.ExecutorType), OnProgress: onProgress, RemoteContributions: remoteContributions, ContributionDestinations: contributionDestinations, @@ -1342,6 +1342,15 @@ func (m *Manager) publishLaunchPrepareCompleted(req *LaunchRequest, result *EnvP // If req.SessionID is empty (quick chat / pre-session contexts), no // deduplication key exists and we fall through to direct execution. func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecution, error) { + // AC-EXECUTORS-SURVIVAL-002.8/002.16: refuse rather than queue or block a + // launch for a session whose recovery guard is currently held. Checked + // first, before any activity lease or singleflight coalescing, so a + // guarded session can never partially acquire launch-path state. + if req.SessionID != "" { + if err := m.recoveryGuard.CheckLaunchAllowed(req.SessionID); err != nil { + return nil, err + } + } if req.SessionID == "" { activityLease, err := m.acquireActivity(ctx, activity.KindExecutionStarting) if err != nil { @@ -1807,6 +1816,12 @@ func (m *Manager) registerAndPublishExecution( } return fmt.Errorf("failed to register execution: %w", addErr) } + // This execution is durably in the store as of the Add above, and it got + // there via Launch -- never via the recovery path, which adds directly to + // executionStore and marks retrackedSessions instead -- so sessionID's row + // is "created this lifetime" from this point on (see + // standaloneOwnSessions). + m.markSessionCreatedThisLifetime(sessionID) isKubernetes := execution.RuntimeName == agentruntime.RuntimeKubernetes var createdRuntimeSecrets map[string]bool if isKubernetes { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go index f9899c45f4..5d3da0b07f 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go @@ -12,6 +12,7 @@ import ( "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/agentctl/tracing" agentctltypes "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -43,17 +44,103 @@ func (m *Manager) Start(ctx context.Context) error { } } - // Try to recover executions from all runtimes - recovered, err := m.executorRegistry.RecoverAll(ctx) - if err != nil { - m.logger.Warn("failed to recover executions from some runtimes", zap.Error(err)) + // AC-EXECUTORS-SURVIVAL-003.1: start this pass's re-tracked-session set + // empty so a stale entry from an earlier Start() call (tests, or a + // hypothetical re-Start) never leaks into this pass's outcome. + m.resetRetrackedSessions() + + // Read the live standalone recovery-inventory records (startup step 3, + // AC-EXECUTORS-SURVIVAL-002.8) before recovery contacts any control + // server, and hand them to every runtime's RecoverInstances unchanged. + records, listErr := m.ListLiveStandaloneExecutorsRunning(ctx) + if listErr != nil { + // A failed read leaves the record set unknown, which is not the same + // thing as empty. AC-EXECUTORS-SURVIVAL-002.6 stops a live instance + // because it is known to have no record; passing an unknown set on as + // an empty one would make every live instance look record-less and + // send all of them down that orphan-stop path, so a transient database + // error during startup would kill every agent that just survived the + // restart. Recover nothing instead, on the same terms + // AC-EXECUTORS-SURVIVAL-002.12 sets for the mirror failure -- the + // adopted server that cannot be enumerated: report no recovered + // instances, stop no instance, and leave every record to the existing + // stale-execution repair path. + m.logger.Error("skipping recovery: live standalone recovery-inventory records could not be read, so no live instance can be correlated to a session", + zap.Error(listErr)) + records = nil } + + // Take a recovery guard for every named session except a confirmed + // passthrough one, BEFORE any control server is contacted, so a + // concurrent launch request cannot race re-tracking + // (AC-EXECUTORS-SURVIVAL-002.8, AC-EXECUTORS-SURVIVAL-005.3). No lookup + // configured is treated the same as a failed read: every session is + // guarded rather than excluded. + passthroughLookup := m.passthroughLookup + if passthroughLookup == nil { + passthroughLookup = func(context.Context, string) (bool, bool) { return false, false } + } + guardedSessions := SessionsToGuard(ctx, sessionIDsFromExecutorRunning(records), passthroughLookup) + for _, sessionID := range guardedSessions { + m.recoveryGuard.AcquireOrObserve(sessionID) + } + + // AC-EXECUTORS-SURVIVAL-005.3: the same classification also decides what + // re-tracking may reach. A confirmed-passthrough session owns a real + // agentctl instance that does outlive this backend, so without this the + // enumeration would correlate it to its record and re-track a session + // whose PTY agent died with the backend -- reporting it as having + // survived, which the AC forbids. Only standalone records reach here + // (ListLiveStandaloneExecutorsRunning), and StandaloneExecutor is the + // only backend that reads them, so this narrows nothing else. + records = recoverableRecords(records, guardedSessions) + + // AC-EXECUTORS-SURVIVAL-003.7: bound this pass's + // adoption+enumeration+reconstruction work with a single deadline, clocked + // from this launch's first control-server contact (or, absent one, from + // right now). recoveryCtx now covers not just RecoverAll (the producer) + // but every per-instance reconstruction step in the consumer loop below, + // per design 02's "Startup" steps 4-7 sharing one deadline. A backend + // implementation that honors ctx.Deadline() (see + // StandaloneExecutor.RecoverInstances) may still let work already in + // flight at the deadline finish in the background rather than aborting + // it -- cancelling recoveryCtx once this pass is over only stops the + // deadline timer itself from leaking, it never reaches that background + // work, which deliberately runs against context.Background() instead. + recoveryCtx, cancelRecovery := context.WithDeadline(ctx, m.recoveryDeadlineDeadline()) + var recovered []*ExecutorInstance + if listErr == nil { + var err error + recovered, err = m.executorRegistry.RecoverAll(recoveryCtx, records) + if err != nil { + m.logger.Warn("failed to recover executions from some runtimes", zap.Error(err)) + } + } + + var stopWG sync.WaitGroup if len(recovered) > 0 { for _, ri := range recovered { + if recoveryCtx.Err() != nil { + // AC-EXECUTORS-SURVIVAL-003.7: the deadline elapsed before this + // record could be reconstructed -- treat it as not re-tracked + // and stop it (against context.Background(), matching every + // other recovery-time stop: an elapsed deadline must not abort + // a stop already dispatched). + m.logger.Warn("recovery deadline elapsed before this record could be reconstructed; treating as not re-tracked", + zap.String("instance_id", ri.InstanceID), + zap.String("session_id", ri.SessionID)) + m.dispatchUnreconstructableStop(&stopWG, ri) + continue + } execution := &AgentExecution{ - ID: ri.InstanceID, - TaskID: ri.TaskID, - SessionID: ri.SessionID, + ID: ri.InstanceID, + TaskID: ri.TaskID, + SessionID: ri.SessionID, + // AC-EXECUTORS-SURVIVAL-002.14: agent profile identity's + // declared source is the recovery-inventory record's + // execution-profile column, carried onto ri by + // buildRecoveredInstances -- never the adopted instance. + AgentProfileID: ri.AgentProfileID, ContainerID: ri.ContainerID, ContainerIP: ri.ContainerIP, WorkspacePath: ri.WorkspacePath, @@ -65,6 +152,92 @@ func (m *Manager) Start(ctx context.Context) error { standaloneInstanceID: ri.StandaloneInstanceID, standalonePort: ri.StandalonePort, promptDoneCh: make(chan PromptCompletionSignal, 1), + // AC-EXECUTORS-SURVIVAL-002.14: run identity is re-derived from + // the runtime environment, which is itself read back from the + // adopted instance rather than the database (both deliberately + // memory-only, per design 03's "runtime environment" / + // "run identity" rows). + RunID: ri.Env["KANDEV_RUN_ID"], + // AC-EXECUTORS-SURVIVAL-002.14: workspace source roots are read + // back from the adopted instance, never pushed -- agentctl owns + // this allowlist and a rebind may have changed it since launch. + WorkspaceSourceRoots: ri.WorkspaceSourceRoots, + // AC-EXECUTORS-SURVIVAL-002.14: provider session identity comes + // from the adopted instance, which holds the live provider + // session -- never from a durable/database value, which could be + // stale relative to what the instance actually resumed. + ACPSessionID: ri.ProviderSessionID, + } + // AC-EXECUTORS-SURVIVAL-002.14's declared source for task + // identity is the recovery-inventory record alone, never the + // adopted instance (buildRecoveredInstances no longer falls + // back to the instance's self-reported task ID). An empty + // value here means the record itself is authoritatively + // missing it: refuse to re-track, per AC-EXECUTORS-SURVIVAL-002.4, + // rather than trusting the instance's own claim about which + // task it belongs to. + if execution.TaskID == "" { + m.logger.Error("refusing to re-track recovered execution: task identity was not present in the recovery-inventory record", + zap.String("instance_id", execution.ID), + zap.String("session_id", execution.SessionID)) + m.dispatchUnreconstructableStop(&stopWG, ri) + continue + } + // A recovered instance is resuming a live provider session (its + // ACPSessionID above comes straight from the adopted instance), so + // ACP session setup is already complete -- unlike a fresh launch, + // there is no InitializeAndPrompt call on this path to mark it. + // Leaving this false blocks SetSessionMode/SetSessionModel/ + // SetSessionConfigOption ("ACP session is not ready") on every + // re-tracked execution until its next full relaunch. + execution.setSessionInitialized(true) + execution.setRuntimeEnvironment(ri.Env) + if err := m.hydrateRecoveredTaskEnvironmentID(recoveryCtx, execution); err != nil { + m.logger.Error("refusing to re-track recovered execution: task environment identity could not be reconstructed", + zap.String("instance_id", execution.ID), + zap.String("session_id", execution.SessionID), + zap.Error(err)) + m.dispatchUnreconstructableStop(&stopWG, ri) + continue + } + // AC-EXECUTORS-SURVIVAL-002.14: Office profile identity is a new key + // in the same persisted metadata this record already carries, not a + // new source -- an empty value here is a legitimate non-Office + // launch, not a missing reconstruction. + execution.OfficeAgentProfileID = getMetadataString(ri.Metadata, MetadataKeyOfficeAgentProfileID) + // AC-EXECUTORS-SURVIVAL-002.14: agent identity, agent and + // continuation commands and their arguments, and the history + // setting are re-derived from the restored agent profile and the + // agent-type registry, never read from the instance. When the + // re-derivation cannot answer (AC-EXECUTORS-SURVIVAL-002.4: the + // declared source answered and the value is not present -- an + // unresolvable profile or an agent type no longer in the + // registry), this instance is authoritatively missing a required + // value: refuse to re-track it and leave it to the stop path + // AC-EXECUTORS-SURVIVAL-002.6 defines, rather than tracking a + // session the backend cannot safely operate. + if err := m.reDeriveRecoveredAgentIdentity(recoveryCtx, execution); err != nil { + m.logger.Error("refusing to re-track recovered execution: agent identity could not be reconstructed", + zap.String("instance_id", execution.ID), + zap.String("session_id", execution.SessionID), + zap.String("agent_profile_id", execution.AgentProfileID), + zap.Error(err)) + m.dispatchUnreconstructableStop(&stopWG, ri) + continue + } + // AC-EXECUTORS-SURVIVAL-004.2/004.5: retrieve this instance's + // retained turn status before this session's state is published. + // A read failure (retries exhausted) must not publish the session + // as running -- it is authoritatively unknown, not running -- so + // it takes the same not-re-tracked stop path as an + // unreconstructable agent identity above. + turnOutcome, turnOutcomeResult := m.retrieveRecoveredTurnOutcome(recoveryCtx, ri) + if turnOutcomeResult == recoveredTurnOutcomeReadFailed { + m.logger.Error("refusing to re-track recovered execution: turn status could not be retrieved", + zap.String("instance_id", execution.ID), + zap.String("session_id", execution.SessionID)) + m.dispatchUnreconstructableStop(&stopWG, ri) + continue } // Create trace span for the recovered session _, recoverySpan := tracing.TraceSessionRecovered( @@ -96,12 +269,17 @@ func (m *Manager) Start(ctx context.Context) error { continue } m.setRuntimeInterest(execution.SessionID, true) + // AC-EXECUTORS-SURVIVAL-003.1: this execution is durably in the + // store as of the Add above, so its session is re-tracked from + // this point on regardless of which branch below applies the + // retained turn outcome or publishes running. + m.markSessionRetracked(execution.SessionID) // Reconcile the persistence row to match the recovered in-memory ID. // If executors_running.agent_execution_id had drifted (e.g. from a // prior bug or manual edit), the recovered runtime instance is the // truth — overwrite the row to match. No-op if already in sync. - m.persistExecutorRunning(ctx, execution) + m.persistExecutorRunning(recoveryCtx, execution) // Re-seed the base-branch map before reconnecting. A surviving // agentctl instance never passes through waitForAgentctlReady, so @@ -109,11 +287,32 @@ func (m *Manager) Start(ctx context.Context) error { // if it was created by a path that could not supply one — and // their diff stats stay pinned to an integration-branch fallback. if client, releaseClient := execution.AcquireAgentCtlClient(); client != nil { - m.pushTaskBaseBranches(ctx, execution.TaskID, execution.ID, client) - m.pushTaskComparisonTargets(ctx, execution.TaskID, execution.ID, client) + m.pushTaskBaseBranches(recoveryCtx, execution.TaskID, execution.ID, client) + m.pushTaskComparisonTargets(recoveryCtx, execution.TaskID, execution.ID, client) releaseClient() } + // AC-EXECUTORS-SURVIVAL-004.2: apply the retained outcome, or + // publish running when nothing was retained, before streams + // reconnect below -- reconnecting is what would eventually + // deliver the same terminal event live (AC-EXECUTORS-SURVIVAL- + // 004.3/004.4), so this ordering makes the recovery-time + // application the one that wins in the common case. + if turnOutcomeResult == recoveredTurnOutcomeApplied { + m.applyRecoveredTurnOutcome(recoveryCtx, execution, ri, turnOutcome) + } else { + m.publishRecoveredExecutionRunning(recoveryCtx, execution) + } + + // AC-EXECUTORS-SURVIVAL-003.7/002.8: release this session's guard + // now that its outcome has actually been published, rather than + // waiting for every session in this pass to finish (design 02 + // step 7's "releasing each session's guard as its outcome is + // published"). ReleaseAllExceptRetained below still covers any + // session a later layer marked retained-unstoppable or + // stop-in-flight, and any session this loop never reached. + m.recoveryGuard.Release(execution.SessionID) + // Reconnect to workspace streams (shell, git, file changes) in background // This is needed so shell.input, git status, etc. work after backend restart go m.streamManager.ReconnectAll(execution) @@ -122,6 +321,32 @@ func (m *Manager) Start(ctx context.Context) error { } m.logger.Info("recovered executions", zap.Int("count", len(recovered))) } + cancelRecovery() + + // AC-EXECUTORS-SURVIVAL-002.11: every dispatchUnreconstructableStop call + // above ran on its own goroutine so one record's bounded-retry stop + // latency never starves another record's reconstruction of this pass's + // shared deadline budget. Wait for all of them to resolve (each one + // settles its own session's guard via RetainAsUnstoppable or Release + // before returning) so Start remains synchronous overall and + // ReleaseAllExceptRetained below never races a still-resolving stop. + stopWG.Wait() + + // Recovery is synchronous above: by this point every guarded session's + // outcome (re-tracked or not) is already decided, so every guard still + // held for this pass is released now. Most were already released + // individually inside the loop as each session's outcome was published; + // this covers a session whose record was skipped by the deadline check, + // a duplicate-execution anomaly, or anything a later layer marks + // retained-unstoppable or stop-in-flight (AC-EXECUTORS-SURVIVAL-002.16, + // AC-EXECUTORS-SURVIVAL-003.7), which ReleaseAllExceptRetained leaves held. + m.recoveryGuard.ReleaseAllExceptRetained() + + // AC-EXECUTORS-SURVIVAL-003.6: this pass's recovery work (adoption, + // enumeration, and every recovered instance's reconstruction above) is + // synchronous and now complete, so a caller outside a reconciliation + // pass may safely enumerate live standalone instances from this point on. + m.recoveryComplete.Store(true) // Start remote status polling loop for runtimes exposing remote status. m.wg.Add(1) @@ -152,6 +377,188 @@ func (m *Manager) Start(ctx context.Context) error { return nil } +// hydrateRecoveredTaskEnvironmentID fills in a recovered execution's +// task-environment identity (AC-EXECUTORS-SURVIVAL-002.14): the declared +// source is the durable store, via the session's own task-environment +// reference, not the adopted instance -- ExecutorInstance carries no such +// field at all. Task-environment identity is one of AC-EXECUTORS-SURVIVAL-002.3's +// required reconstructed values, so unlike an earlier version of this method +// (which logged and ignored both failure shapes below), neither is silently +// swallowed: a lookup failure is reported to the caller as an +// AC-EXECUTORS-SURVIVAL-002.13 failed read, and a durable store that answers +// successfully with no value at all is the AC-EXECUTORS-SURVIVAL-002.4 +// authoritatively-absent case. A nil provider is a capability this +// deployment never wired at all -- every production backend wires one via +// SetWorkspaceInfoProvider, only bare-bones test doubles don't -- so it is +// treated like any other absent optional recovery capability: skip, not +// refuse. +func (m *Manager) hydrateRecoveredTaskEnvironmentID(ctx context.Context, execution *AgentExecution) error { + if m.workspaceInfoProvider == nil { + return nil + } + info, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, execution.TaskID, execution.SessionID) + if err != nil { + return fmt.Errorf("task environment identity read failed: %w", err) + } + if info == nil || info.TaskEnvironmentID == "" { + return errors.New("task environment identity: durable store answered with no value") + } + execution.TaskEnvironmentID = info.TaskEnvironmentID + return nil +} + +// reDeriveRecoveredAgentIdentity implements AC-EXECUTORS-SURVIVAL-002.14's +// last reconstruction-table row: agent identity, agent and continuation +// commands and their arguments, and the history setting are re-derived from +// the restored agent profile and the agent-type registry -- the same +// computation an ordinary launch performs, never read from the adopted +// instance, so a compromised or stale instance cannot tell the backend which +// command to run for the session's next turn. +// +// Re-derivation reflects the profile and registry as they stand at recovery, +// not as they stood before the restart (AC-EXECUTORS-SURVIVAL-002.3): a +// profile edited or an agent type removed during the outage takes effect +// exactly as it would on the session's next ordinary turn. +// +// getAgentConfigForExecution and buildFreshAgentCommand are the same +// primitives an in-session context reset uses (manager_interaction.go's +// prepareAgentRestart), reused as-is: both already consume nothing but +// execution fields already restored by this point (AgentProfileID, +// RuntimeName, metadata), with no launch-request-only dependency. +func (m *Manager) reDeriveRecoveredAgentIdentity(ctx context.Context, execution *AgentExecution) error { + agentConfig, err := m.getAgentConfigForExecution(execution) + if err != nil { + return fmt.Errorf("agent identity: %w", err) + } + execution.AgentID = agentConfig.ID() + if rt := agentConfig.Runtime(); rt != nil { + execution.historyEnabled = rt.SessionConfig.HistoryContextInjection || + rt.SessionConfig.NewSessionOnWorkspaceRebind + } + commands, err := m.buildFreshAgentCommand(ctx, execution, agentConfig) + if err != nil { + return fmt.Errorf("agent and continuation commands: %w", err) + } + execution.AgentCommand = commands.initial + execution.ContinueCommand = commands.continue_ + execution.AgentArgs = commands.args + execution.ContinueArgs = commands.continueArgs + return nil +} + +// recoveryStopper is the optional capability satisfied by *StandaloneExecutor +// that stops a recovered instance within the bounded per-attempt timeout and +// retry count AC-EXECUTORS-SURVIVAL-002.15 requires for every recovery-time +// stop -- the same budget stopWithRetry already applies to the +// AC-EXECUTORS-SURVIVAL-002.6/002.10 loser/orphan stop paths in +// StandaloneExecutor.RecoverInstances. A backend that doesn't implement it +// falls back to a single StopInstance call: AC-EXECUTORS-SURVIVAL-002.15's +// retry contract covers the WORKTREE and LOCAL executor types only, both +// standalone-runtime. +type recoveryStopper interface { + stopWithRetry(ctx context.Context, instanceID string) error +} + +// dispatchUnreconstructableStop starts stopUnreconstructableRecoveredInstance +// on its own goroutine and registers it on wg, so one record's bounded-retry +// stop latency cannot block the caller's loop from reconstructing every +// other record within the same AC-EXECUTORS-SURVIVAL-003.7 shared deadline +// (AC-EXECUTORS-SURVIVAL-002.11: "no such instance's outcome shall depend on +// another's"). MarkStopInFlight runs synchronously first, before the loop +// can reach ReleaseAllExceptRetained, so this session's guard survives the +// bulk release until stopUnreconstructableRecoveredInstance resolves it +// (Release on success, RetainAsUnstoppable on failure). +func (m *Manager) dispatchUnreconstructableStop(wg *sync.WaitGroup, ri *ExecutorInstance) { + m.recoveryGuard.MarkStopInFlight(ri.SessionID) + wg.Add(1) + go func() { + defer wg.Done() + m.stopUnreconstructableRecoveredInstance(context.Background(), ri) + }() +} + +// stopUnreconstructableRecoveredInstance implements the +// AC-EXECUTORS-SURVIVAL-002.4 refusal path's "leave the instance to the stop +// path defined by AC-EXECUTORS-SURVIVAL-002.6" for an instance whose agent +// identity could not be re-derived: it was never added to the execution +// store, so RemoveExecution's teardown does not apply -- stop the live +// instance directly through its owning runtime backend. Run against +// context.Background(), matching every sibling AC-002.6/002.10 recovery stop +// (executor_standalone.go's dispatchRecoveryStops): an elapsed recovery +// deadline (AC-EXECUTORS-SURVIVAL-003.7) must not abort a stop already in +// flight. Every return path resolves this session's recovery guard +// (AC-EXECUTORS-SURVIVAL-002.16): a live instance this call could not stop +// (no backend for its runtime, or the stop itself failed) retains the guard +// for the rest of this backend's lifetime rather than letting it fall +// through to a bulk release that would let a fresh launch race the still-live +// instance; the instance is left to the existing stale-execution repair path +// like any other recovery-time stop failure that isn't part of the joint +// winner/loser accounting. +func (m *Manager) stopUnreconstructableRecoveredInstance(ctx context.Context, ri *ExecutorInstance) { + backend, err := m.executorRegistry.GetBackend(ri.RuntimeName) + if err != nil { + m.logger.Warn("cannot stop unreconstructable recovered instance: no backend for its runtime", + zap.String("instance_id", ri.InstanceID), + zap.String("session_id", ri.SessionID), + zap.String("runtime", string(ri.RuntimeName)), + zap.Error(err)) + m.recoveryGuard.RetainAsUnstoppable(ri.SessionID) + return + } + if stopper, ok := backend.(recoveryStopper); ok { + if err := stopper.stopWithRetry(context.Background(), ri.StandaloneInstanceID); err != nil { + m.logger.Warn("failed to stop unreconstructable recovered instance after exhausting retries", + zap.String("instance_id", ri.InstanceID), + zap.String("session_id", ri.SessionID), + zap.Error(err)) + m.recoveryGuard.RetainAsUnstoppable(ri.SessionID) + return + } + m.recoveryGuard.Release(ri.SessionID) + return + } + if err := backend.StopInstance(ctx, ri, true); err != nil { + m.logger.Warn("failed to stop unreconstructable recovered instance", + zap.String("instance_id", ri.InstanceID), + zap.String("session_id", ri.SessionID), + zap.Error(err)) + m.recoveryGuard.RetainAsUnstoppable(ri.SessionID) + return + } + m.recoveryGuard.Release(ri.SessionID) +} + +// defaultRecoveryDeadline is the AC-EXECUTORS-SURVIVAL-003.7 fallback used +// when no recovery deadline was configured (recoveryDeadline left zero). +const defaultRecoveryDeadline = 30 * time.Second + +// recoveryDeadlineDeadline resolves the wall-clock instant this startup +// pass's recovery work must stop admitting new work by, per +// AC-EXECUTORS-SURVIVAL-003.7. +func (m *Manager) recoveryDeadlineDeadline() time.Time { + start := m.recoveryDeadlineStart + if start.IsZero() { + start = time.Now() + } + period := m.recoveryDeadline + if period <= 0 { + period = defaultRecoveryDeadline + } + return start.Add(period) +} + +// sessionIDsFromExecutorRunning extracts the distinct, non-empty session +// identities named by a set of recovery-inventory records. +func sessionIDsFromExecutorRunning(records []*models.ExecutorRunning) []string { + sessionIDs := make([]string, 0, len(records)) + for _, rec := range records { + if rec != nil && rec.SessionID != "" { + sessionIDs = append(sessionIDs, rec.SessionID) + } + } + return sessionIDs +} + // GetRecoveredExecutions returns a snapshot of all currently tracked executions // This can be used by the orchestrator to sync with the database func (m *Manager) GetRecoveredExecutions() []RecoveredExecution { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_recovery_deadline_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_recovery_deadline_test.go new file mode 100644 index 0000000000..e619d6cd58 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_recovery_deadline_test.go @@ -0,0 +1,167 @@ +package lifecycle + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kandev/kandev/internal/agent/executor" + "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/task/models" +) + +// deadlineRecoveringExecutor is a minimal ExecutorBackend that returns a +// fixed set of recovered instances from RecoverInstances (unlike +// guardObservingExecutor, which always returns none) and also implements +// the optional recoveryStopper capability, so a test can observe whether a +// recovered instance's not-re-tracked stop used the bounded-retry path. +type deadlineRecoveringExecutor struct { + name executor.Name + recovered []*ExecutorInstance + stopWithRetryCalls []string +} + +func (e *deadlineRecoveringExecutor) Name() executor.Name { return e.name } +func (e *deadlineRecoveringExecutor) HealthCheck(context.Context) error { return nil } +func (e *deadlineRecoveringExecutor) CreateInstance(context.Context, *ExecutorCreateRequest) (*ExecutorInstance, error) { + return nil, nil +} +func (e *deadlineRecoveringExecutor) StopInstance(context.Context, *ExecutorInstance, bool) error { + return nil +} +func (e *deadlineRecoveringExecutor) RecoverInstances(context.Context, []*models.ExecutorRunning) ([]*ExecutorInstance, error) { + return e.recovered, nil +} +func (e *deadlineRecoveringExecutor) GetInteractiveRunner() *process.InteractiveRunner { return nil } +func (e *deadlineRecoveringExecutor) RequiresCloneURL() bool { return false } +func (e *deadlineRecoveringExecutor) ShouldApplyPreferredShell() bool { return false } +func (e *deadlineRecoveringExecutor) IsAlwaysResumable() bool { return false } +func (e *deadlineRecoveringExecutor) stopWithRetry(_ context.Context, instanceID string) error { + e.stopWithRetryCalls = append(e.stopWithRetryCalls, instanceID) + return nil +} + +// TestManagerStartTreatsRecordsAsNotRetrackedOnceRecoveryDeadlineElapses +// pins Review round 1 finding 2 (AC-EXECUTORS-SURVIVAL-003.7): once the +// recovery deadline has elapsed, a record the consumer loop has not yet +// reconstructed must be treated as not re-tracked -- stopped via the bounded +// retry path and never added to the execution store or published running -- +// rather than reconstructed anyway on the ambient unbounded context. +func TestManagerStartTreatsRecordsAsNotRetrackedOnceRecoveryDeadlineElapses(t *testing.T) { + log := newTestLogger() + execRegistry := NewExecutorRegistry(log) + backend := &deadlineRecoveringExecutor{ + name: executor.NameStandalone, + recovered: []*ExecutorInstance{ + { + InstanceID: "exec-1", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: "profile-1", + RuntimeName: executor.NameStandalone, + StandaloneInstanceID: "standalone-1", + }, + }, + } + execRegistry.Register(backend) + + mgr := NewManager(newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + mgr.SetExecutorRunningWriter(&listingWriter{rows: []*models.ExecutorRunning{{SessionID: "session-1"}}}) + // The recovery deadline already elapsed before Start() is even called: + // its clock started an hour ago and the configured bound is far shorter. + mgr.SetRecoveryDeadlineStart(time.Now().Add(-time.Hour)) + mgr.SetRecoveryDeadline(time.Millisecond) + + if err := mgr.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + + if _, exists := mgr.executionStore.GetBySessionID("session-1"); exists { + t.Fatal("session-1 was added to the execution store despite the recovery deadline having elapsed") + } + if len(backend.stopWithRetryCalls) != 1 || backend.stopWithRetryCalls[0] != "standalone-1" { + t.Fatalf("stopWithRetry calls = %v, want exactly one call for standalone-1", backend.stopWithRetryCalls) + } + // The guard must not be left held forever: ReleaseAllExceptRetained + // still runs after the loop even though this session was never + // individually released inside it. + if err := mgr.recoveryGuard.CheckLaunchAllowed("session-1"); err != nil { + t.Fatalf("expected session-1's guard released once Start finished, got %v", err) + } +} + +// guardObservingProfileResolver wraps a ProfileResolver and records the +// recovery guard's state for one session at the moment ResolveProfile is +// called -- a point squarely inside the consumer loop's per-instance +// reconstruction (reDeriveRecoveredAgentIdentity), well before that +// session's outcome is added to the execution store or published. +type guardObservingProfileResolver struct { + mgr *Manager + sessionID string + guardErrDuringResolve error + observed bool +} + +func (r *guardObservingProfileResolver) ResolveProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) { + r.guardErrDuringResolve = r.mgr.recoveryGuard.CheckLaunchAllowed(r.sessionID) + r.observed = true + return &AgentProfileInfo{ + ProfileID: profileID, + ProfileName: "Test Profile", + AgentID: "augment-agent", + AgentName: "auggie", + Model: "claude-sonnet-4-20250514", + }, nil +} + +// TestManagerStartHoldsGuardThroughReconstructionAndReleasesAfterPublish +// pins Review round 1 finding 3: a recovered session's guard must still be +// held while that session's instance is being reconstructed (identity +// re-derivation, turn-outcome read) and is released only once its outcome +// is actually published -- not released in bulk immediately after RecoverAll +// returns, before any reconstruction for it has even started. Under the +// pre-fix code the bulk release ran before this loop began at all, so the +// guard-observing hook below would have found it already released. +func TestManagerStartHoldsGuardThroughReconstructionAndReleasesAfterPublish(t *testing.T) { + log := newTestLogger() + execRegistry := NewExecutorRegistry(log) + backend := &deadlineRecoveringExecutor{ + name: executor.NameStandalone, + recovered: []*ExecutorInstance{ + { + InstanceID: "exec-1", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: "profile-1", + RuntimeName: executor.NameStandalone, + StandaloneInstanceID: "standalone-1", + }, + }, + } + execRegistry.Register(backend) + + resolver := &guardObservingProfileResolver{sessionID: "session-1"} + mgr := NewManager(newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, resolver, nil, ExecutorFallbackWarn, "", log) + resolver.mgr = mgr + cleanupManagerStopCh(t, mgr) + mgr.SetExecutorRunningWriter(&listingWriter{rows: []*models.ExecutorRunning{{SessionID: "session-1"}}}) + + if err := mgr.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + + if !resolver.observed { + t.Fatal("ResolveProfile was never called; the guard-observing hook did not fire") + } + if !errors.Is(resolver.guardErrDuringResolve, ErrSessionRecoveryGuarded) { + t.Fatalf("guard state mid-reconstruction = %v, want ErrSessionRecoveryGuarded (guard must still be held)", resolver.guardErrDuringResolve) + } + if _, exists := mgr.executionStore.GetBySessionID("session-1"); !exists { + t.Fatal("session-1 was not re-tracked despite recovering well within the deadline") + } + if err := mgr.recoveryGuard.CheckLaunchAllowed("session-1"); err != nil { + t.Fatalf("expected session-1's guard released once its outcome was published, got %v", err) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_test.go index f863884dde..733c20f278 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_test.go @@ -13,6 +13,7 @@ import ( "github.com/kandev/kandev/internal/agent/executor" agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -335,7 +336,7 @@ func TestManagerStopAllAgentsPassesBackendShutdownReason(t *testing.T) { t.Fatalf("StopInstance reason = %q, want %q", mock.stopReason, StopReasonBackendShutdown) } } -func (m *mockStopTracker) RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) { +func (m *mockStopTracker) RecoverInstances(ctx context.Context, records []*models.ExecutorRunning) ([]*ExecutorInstance, error) { return nil, nil } func (m *mockStopTracker) GetInteractiveRunner() *process.InteractiveRunner { @@ -400,9 +401,10 @@ func TestManager_StartSeedsRecoveredExecution(t *testing.T) { execRegistry.Register(&MockExecutor{ name: executor.NameStandalone, recoverInstances: []*ExecutorInstance{{ - InstanceID: "exec-recovered", - TaskID: "task-recovered", - Client: client, + InstanceID: "exec-recovered", + TaskID: "task-recovered", + AgentProfileID: "profile-1", + Client: client, }}, }) mgr := NewManager(newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, ExecutorFallbackWarn, "", log) @@ -424,3 +426,39 @@ func TestManager_StartSeedsRecoveredExecution(t *testing.T) { t.Fatal("timed out waiting for recovered execution base-branch seed") } } + +// TestManagerStartRefusesRecoveredExecutionWithEmptyTaskID pins Review round +// 3, finding 3: a recovered instance whose TaskID is empty (the +// recovery-inventory record's declared source answered with nothing -- +// executor_standalone.go's buildRecoveredInstances no longer falls back to +// the instance's own claim) is authoritatively missing a required value +// (AC-EXECUTORS-SURVIVAL-002.4): it must never be published as a tracked +// execution, and is instead stopped through the AC-EXECUTORS-SURVIVAL-002.6 +// stop path. +func TestManagerStartRefusesRecoveredExecutionWithEmptyTaskID(t *testing.T) { + log := newTestRegistryLogger() + execRegistry := NewExecutorRegistry(log) + mockExec := &MockExecutor{ + name: executor.NameStandalone, + recoverInstances: []*ExecutorInstance{{ + InstanceID: "exec-no-task", + TaskID: "", + SessionID: "session-no-task", + RuntimeName: executor.NameStandalone, + }}, + } + execRegistry.Register(mockExec) + mgr := NewManager(newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, ExecutorFallbackWarn, "", log) + t.Cleanup(func() { _ = mgr.Stop() }) + + if err := mgr.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + + if _, ok := mgr.GetExecution("exec-no-task"); ok { + t.Fatal("an execution with an empty TaskID must not be tracked") + } + if len(mockExec.stopInstanceCalls) != 1 || mockExec.stopInstanceCalls[0].InstanceID != "exec-no-task" { + t.Fatalf("stopInstanceCalls = %+v, want the unreconstructable instance stopped", mockExec.stopInstanceCalls) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_liveness_ownership_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_liveness_ownership_test.go new file mode 100644 index 0000000000..e5408ca6f4 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_liveness_ownership_test.go @@ -0,0 +1,59 @@ +package lifecycle + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +// TestManagerLaunchMarksSessionCreatedThisLifetime pins the own-record side +// of design 02's "own-record-created-this-lifetime vs inherited-record" +// carve-out (Layer 6.7): a session launched fresh through Manager.Launch is +// "created this lifetime", never a session Manager has simply heard of +// through some other path. +func TestManagerLaunchMarksSessionCreatedThisLifetime(t *testing.T) { + mgr, _ := newEnvironmentExecutionTestManager(t, nil) + + if mgr.wasCreatedThisLifetime("session-launched") { + t.Fatal("expected session to NOT be marked before Launch is called") + } + + _, err := mgr.Launch(context.Background(), &LaunchRequest{ + TaskID: "task-launched", + SessionID: "session-launched", + AgentProfileID: "profile-launched", + ExecutorType: string(models.ExecutorTypeLocal), + IsEphemeral: true, + }) + if err != nil { + t.Fatalf("Manager.Launch: %v", err) + } + + if !mgr.wasCreatedThisLifetime("session-launched") { + t.Fatal("expected session to be marked created-this-lifetime after a successful Launch") + } + if mgr.wasCreatedThisLifetime("some-other-session") { + t.Fatal("expected an unrelated session to remain unmarked") + } + if mgr.wasCreatedThisLifetime("") { + t.Fatal("expected an empty session ID to never be marked") + } +} + +// TestManagerMarkSessionCreatedThisLifetimeNeverReset pins that, unlike +// retrackedSessions, standaloneOwnSessions must survive across a Start() +// call -- it needs to accumulate for the whole process lifetime, not just +// one recovery pass. +func TestManagerMarkSessionCreatedThisLifetimeNeverReset(t *testing.T) { + mgr, _ := newEnvironmentExecutionTestManager(t, nil) + mgr.markSessionCreatedThisLifetime("session-own") + + if err := mgr.Start(context.Background()); err != nil { + t.Fatalf("Manager.Start: %v", err) + } + + if !mgr.wasCreatedThisLifetime("session-own") { + t.Fatal("expected standaloneOwnSessions to survive a Start() call, unlike retrackedSessions") + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_scope_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_scope_test.go new file mode 100644 index 0000000000..2d117771e1 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_scope_test.go @@ -0,0 +1,145 @@ +package lifecycle + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/agent/executor" + "github.com/kandev/kandev/internal/task/models" +) + +// TestStopAgentWithReasonTerminatesPassthroughOnBackendShutdownWhenSurvivalEnabled +// pins AC-EXECUTORS-SURVIVAL-005.3's "never detached" clause. A passthrough +// session's agent runs on a terminal owned by this backend process, so it dies +// with the backend whatever the control server does. StopAllAgents calls +// StopAgentWithReason with StopReasonBackendShutdown for every tracked +// execution, including passthrough ones on the standalone runtime, so without +// the exclusion such a session takes the survivable-detach branch: no +// StopInstance, no agent.stopped, and an executors_running row left claiming a +// live agent that no later pass repairs (AC-EXECUTORS-SURVIVAL-004.5). +func TestStopAgentWithReasonTerminatesPassthroughOnBackendShutdownWhenSurvivalEnabled(t *testing.T) { + mgr, mockExecutor := newDetachTestManager(t, true) + execution := newDetachTestExecution("exec-passthrough", "session-passthrough") + execution.IsPassthrough = true + require.NoError(t, mgr.executionStore.Add(execution)) + + err := mgr.StopAgentWithReason(context.Background(), execution.ID, StopReasonBackendShutdown, false) + require.NoError(t, err) + + require.Len(t, mockExecutor.stopInstanceCalls, 1, + "a passthrough session must behave as if the capability were disabled: the runtime instance is still stopped") + + eventBus := mgr.eventBus.(*MockEventBus) + require.True(t, hasAgentStoppedEvent(eventBus.PublishedEvents), + "a passthrough session must still publish agent.stopped: its PTY agent is genuinely gone") +} + +// TestStopAgentWithReasonTerminatesPassthroughIdentifiedByProcessID covers the +// second half of the in-memory passthrough idiom this package already uses +// (IsAgentReadyForPrompt, RecoverAgentPromptStream): an execution whose +// terminal process exists is passthrough even if the flag was not carried. +func TestStopAgentWithReasonTerminatesPassthroughIdentifiedByProcessID(t *testing.T) { + mgr, mockExecutor := newDetachTestManager(t, true) + execution := newDetachTestExecution("exec-passthrough-pid", "session-passthrough-pid") + execution.PassthroughProcessID = "pty-1" + require.NoError(t, mgr.executionStore.Add(execution)) + + err := mgr.StopAgentWithReason(context.Background(), execution.ID, StopReasonBackendShutdown, false) + require.NoError(t, err) + + require.Len(t, mockExecutor.stopInstanceCalls, 1, + "an execution carrying a terminal process is passthrough and must take the terminating path") + eventBus := mgr.eventBus.(*MockEventBus) + require.True(t, hasAgentStoppedEvent(eventBus.PublishedEvents), "a terminating stop must publish agent.stopped") +} + +// TestManagerStartExcludesPassthroughRecordFromRecovery pins +// AC-EXECUTORS-SURVIVAL-005.3's "never re-tracked" clause at the seam that +// decides it. A passthrough session owns a real agentctl instance -- created +// by CreateInstance like any other execution, or promoted from a +// workspace-only one -- so that instance really does outlive this backend and +// would correlate to the session's recovery-inventory record. Handing the +// record to RecoverInstances is therefore all it takes to re-track a session +// whose agent is gone and report it as having survived. +func TestManagerStartExcludesPassthroughRecordFromRecovery(t *testing.T) { + log := newTestLogger() + execRegistry := NewExecutorRegistry(log) + + var recovered []*models.ExecutorRunning + mock := &guardObservingExecutor{ + name: executor.NameStandalone, + onRecoverInstances: func(_ context.Context, records []*models.ExecutorRunning) { + recovered = records + }, + } + execRegistry.Register(mock) + + mgr := NewManager(newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + + mgr.SetExecutorRunningWriter(&listingWriter{rows: []*models.ExecutorRunning{ + {SessionID: "session-normal"}, + {SessionID: "session-passthrough"}, + {SessionID: "session-unreadable"}, + }}) + mgr.SetPassthroughLookup(func(_ context.Context, sessionID string) (bool, bool) { + switch sessionID { + case "session-passthrough": + return true, true + case "session-unreadable": + return false, false + default: + return false, true + } + }) + + require.NoError(t, mgr.Start(context.Background())) + + sessions := make([]string, 0, len(recovered)) + for _, record := range recovered { + sessions = append(sessions, record.SessionID) + } + require.NotContains(t, sessions, "session-passthrough", + "a confirmed-passthrough record must never reach re-tracking") + require.Contains(t, sessions, "session-normal", "an ordinary standalone record must still be recovered") + require.Contains(t, sessions, "session-unreadable", + "a session whose passthrough mode could not be read is guarded rather than excluded, so it stays recoverable") +} + +// TestRecoverableRecordsKeepsEverythingButConfirmedPassthrough pins the +// filter's exact scope: it removes only records the pass classified as +// passthrough. A record with no session identity is not something the pass +// could classify either way, and correlation already skips it, so dropping it +// here would narrow recovery for a reason the AC does not give. +func TestRecoverableRecordsKeepsEverythingButConfirmedPassthrough(t *testing.T) { + records := []*models.ExecutorRunning{ + {SessionID: "guarded-a"}, + {SessionID: "passthrough"}, + {SessionID: ""}, + nil, + {SessionID: "guarded-b"}, + } + + got := recoverableRecords(records, []string{"guarded-a", "guarded-b"}) + + sessions := make([]string, 0, len(got)) + for _, record := range got { + require.NotNil(t, record) + sessions = append(sessions, record.SessionID) + } + require.Equal(t, []string{"guarded-a", "", "guarded-b"}, sessions) +} + +// TestRecoverableRecordsKeepsEveryRecordWhenNothingIsExcluded guards the +// no-passthrough case, which is every ordinary installation: the filter must +// be a pass-through when SessionsToGuard excluded nothing. +func TestRecoverableRecordsKeepsEveryRecordWhenNothingIsExcluded(t *testing.T) { + records := []*models.ExecutorRunning{{SessionID: "a"}, {SessionID: "b"}} + got := recoverableRecords(records, []string{"a", "b"}) + require.Len(t, got, 2) + require.Equal(t, records[0], got[0]) + require.Equal(t, records[1], got[1]) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_duplicate_session_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_duplicate_session_test.go new file mode 100644 index 0000000000..a16e2e82e2 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_duplicate_session_test.go @@ -0,0 +1,91 @@ +package lifecycle + +import ( + "context" + "testing" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/kandev/kandev/internal/agent/executor" + "github.com/kandev/kandev/internal/common/logger" +) + +// TestManagerStartSkipsDuplicateSessionAcrossRuntimesDuringRecovery pins +// AC-EXECUTORS-SURVIVAL-002.9: if two different runtime backends each report +// a live, otherwise-reconstructable instance for the very same session ID in +// one Manager.Start recovery pass (the "should not happen... DB consistency +// issue" case manager_lifecycle.go's executionStore.Add call already guards), +// exactly one of them lands in the store -- never two, and never a panic or +// corrupted index. CorrelateRecoveryInstances' per-runtime Winners map cannot +// itself produce this (it is keyed by session ID within one runtime), but +// RecoverAll concatenates every registered runtime's winners without any +// cross-runtime session dedup, so this collision is reachable in practice +// when the same session is (incorrectly) live on two runtimes at once. +func TestManagerStartSkipsDuplicateSessionAcrossRuntimesDuringRecovery(t *testing.T) { + core, logs := observer.New(zapcore.ErrorLevel) + log, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatal(err) + } + + registry := NewExecutorRegistry(log) + standaloneBackend := &MockExecutor{ + name: executor.NameStandalone, + recoverInstances: []*ExecutorInstance{{ + InstanceID: "exec-standalone", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: recoveryTestAgentProfileID, + RuntimeName: executor.NameStandalone, + StandaloneInstanceID: "std-inst-1", + }}, + } + dockerBackend := &MockExecutor{ + name: executor.NameDocker, + recoverInstances: []*ExecutorInstance{{ + InstanceID: "exec-docker", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: recoveryTestAgentProfileID, + RuntimeName: executor.NameDocker, + }}, + } + registry.Register(standaloneBackend) + registry.Register(dockerBackend) + + eventBus := &MockEventBus{} + mgr := NewManager(newTestRegistry(), eventBus, registry, nil, nil, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) + + if err := mgr.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + + execution, ok := mgr.GetExecutionBySessionID("session-1") + if !ok { + t.Fatal("expected exactly one of the two colliding instances to be tracked for session-1") + } + if execution.ID != "exec-standalone" && execution.ID != "exec-docker" { + t.Fatalf("tracked execution id = %q, want one of exec-standalone/exec-docker", execution.ID) + } + + // The loser must never be independently reachable: it was rejected by + // ExecutionStore.Add before insertion, not raced into the map afterward. + loserID := "exec-docker" + if execution.ID == loserID { + loserID = "exec-standalone" + } + if _, ok := mgr.GetExecution(loserID); ok { + t.Fatalf("loser execution %q was unexpectedly tracked alongside the winner", loserID) + } + + entries := logs.FilterMessage("skipping duplicate execution during recovery").All() + if len(entries) != 1 { + t.Fatalf("duplicate-skip log entries = %d, want exactly 1", len(entries)) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_guard_wiring_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_guard_wiring_test.go new file mode 100644 index 0000000000..6454e21f99 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_guard_wiring_test.go @@ -0,0 +1,183 @@ +package lifecycle + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/agent/executor" + "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/task/models" +) + +// guardObservingExecutor is a minimal ExecutorBackend whose RecoverInstances +// calls back into the test so it can observe recovery-guard state from +// inside the same synchronous call Manager.Start makes, before +// ReleaseAllExceptRetained runs. +type guardObservingExecutor struct { + name executor.Name + onRecoverInstances func(ctx context.Context, records []*models.ExecutorRunning) + stopInstanceCalls int +} + +func (e *guardObservingExecutor) Name() executor.Name { return e.name } +func (e *guardObservingExecutor) HealthCheck(context.Context) error { return nil } +func (e *guardObservingExecutor) CreateInstance(context.Context, *ExecutorCreateRequest) (*ExecutorInstance, error) { + return nil, nil +} +func (e *guardObservingExecutor) StopInstance(context.Context, *ExecutorInstance, bool) error { + e.stopInstanceCalls++ + return nil +} +func (e *guardObservingExecutor) RecoverInstances(ctx context.Context, records []*models.ExecutorRunning) ([]*ExecutorInstance, error) { + if e.onRecoverInstances != nil { + e.onRecoverInstances(ctx, records) + } + return nil, nil +} +func (e *guardObservingExecutor) GetInteractiveRunner() *process.InteractiveRunner { return nil } +func (e *guardObservingExecutor) RequiresCloneURL() bool { return false } +func (e *guardObservingExecutor) ShouldApplyPreferredShell() bool { return false } +func (e *guardObservingExecutor) IsAlwaysResumable() bool { return false } + +// TestManagerStartGuardsLiveStandaloneSessionsExceptPassthroughAndReleasesAfterRecovery +// pins AC-EXECUTORS-SURVIVAL-002.8/005.3: every session named by a live +// standalone recovery-inventory record is guarded before any runtime's +// RecoverInstances is called, a confirmed-passthrough session is excluded, +// and every non-retained guard is released once recovery completes. +func TestManagerStartGuardsLiveStandaloneSessionsExceptPassthroughAndReleasesAfterRecovery(t *testing.T) { + log := newTestLogger() + execRegistry := NewExecutorRegistry(log) + + var mgr *Manager + var guardedErr, passthroughErr error + mock := &guardObservingExecutor{ + name: executor.NameStandalone, + onRecoverInstances: func(context.Context, []*models.ExecutorRunning) { + guardedErr = mgr.recoveryGuard.CheckLaunchAllowed("session-guarded") + passthroughErr = mgr.recoveryGuard.CheckLaunchAllowed("session-passthrough") + }, + } + execRegistry.Register(mock) + + mgr = NewManager(newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + + mgr.SetExecutorRunningWriter(&listingWriter{rows: []*models.ExecutorRunning{ + {SessionID: "session-guarded"}, + {SessionID: "session-passthrough"}, + }}) + mgr.SetPassthroughLookup(func(_ context.Context, sessionID string) (bool, bool) { + return sessionID == "session-passthrough", true + }) + + if err := mgr.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + + if guardedErr == nil { + t.Fatal("expected session-guarded to be guarded during recovery") + } + if !errors.Is(guardedErr, ErrSessionRecoveryGuarded) { + t.Fatalf("session-guarded guard check = %v, want ErrSessionRecoveryGuarded", guardedErr) + } + if passthroughErr != nil { + t.Fatalf("expected passthrough session to be excluded from guarding, got %v", passthroughErr) + } + + if err := mgr.recoveryGuard.CheckLaunchAllowed("session-guarded"); err != nil { + t.Fatalf("expected guard released once recovery finished, got %v", err) + } +} + +// TestManagerStartGuardsSessionWhenPassthroughLookupUnconfigured pins the +// documented default: with no SetPassthroughLookup call, every live +// standalone session is guarded rather than excluded. +func TestManagerStartGuardsSessionWhenPassthroughLookupUnconfigured(t *testing.T) { + log := newTestLogger() + execRegistry := NewExecutorRegistry(log) + + var mgr *Manager + var guardedErr error + mock := &guardObservingExecutor{ + name: executor.NameStandalone, + onRecoverInstances: func(context.Context, []*models.ExecutorRunning) { + guardedErr = mgr.recoveryGuard.CheckLaunchAllowed("session-no-lookup") + }, + } + execRegistry.Register(mock) + + mgr = NewManager(newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + + mgr.SetExecutorRunningWriter(&listingWriter{rows: []*models.ExecutorRunning{ + {SessionID: "session-no-lookup"}, + }}) + + if err := mgr.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + + if !errors.Is(guardedErr, ErrSessionRecoveryGuarded) { + t.Fatalf("session-no-lookup guard check = %v, want ErrSessionRecoveryGuarded", guardedErr) + } +} + +// TestManagerLaunchRefusedForGuardedSession pins that Launch checks the +// recovery guard before doing anything else (AC-EXECUTORS-SURVIVAL-002.8): +// a session whose guard is currently held is refused immediately with the +// retryable sentinel, not queued behind recovery. +func TestManagerLaunchRefusedForGuardedSession(t *testing.T) { + mgr := newTestManager(t) + mgr.recoveryGuard.AcquireOrObserve("session-guarded") + + execution, err := mgr.Launch(context.Background(), &LaunchRequest{ + TaskID: "task-guarded", + SessionID: "session-guarded", + }) + if execution != nil { + t.Fatalf("expected nil execution, got %+v", execution) + } + if !errors.Is(err, ErrSessionRecoveryGuarded) { + t.Fatalf("Launch error = %v, want ErrSessionRecoveryGuarded", err) + } +} + +// TestManagerLaunchRefusedForRetainedUnstoppableSession pins the +// AC-EXECUTORS-SURVIVAL-002.16 non-retryable refusal. +func TestManagerLaunchRefusedForRetainedUnstoppableSession(t *testing.T) { + mgr := newTestManager(t) + mgr.recoveryGuard.RetainAsUnstoppable("session-unstoppable") + + execution, err := mgr.Launch(context.Background(), &LaunchRequest{ + TaskID: "task-unstoppable", + SessionID: "session-unstoppable", + }) + if execution != nil { + t.Fatalf("expected nil execution, got %+v", execution) + } + if !errors.Is(err, ErrSessionUnstoppableAgent) { + t.Fatalf("Launch error = %v, want ErrSessionUnstoppableAgent", err) + } +} + +// TestManagerLaunchProceedsForUnguardedSession pins that the new guard check +// does not interfere with an ordinary launch for a session that was never +// guarded. +func TestManagerLaunchProceedsForUnguardedSession(t *testing.T) { + mgr, backend := newEnvironmentExecutionTestManager(t, nil) + + _, err := mgr.Launch(context.Background(), &LaunchRequest{ + TaskID: "task-unguarded", + SessionID: "session-unguarded", + AgentProfileID: "profile-unguarded", + ExecutorType: string(models.ExecutorTypeLocal), + IsEphemeral: true, + }) + if err != nil { + t.Fatalf("Manager.Launch: %v", err) + } + if backend.lastRequest == nil { + t.Fatal("expected executor CreateInstance to be called for an unguarded session") + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_inventory_unknown_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_inventory_unknown_test.go new file mode 100644 index 0000000000..bb34db2531 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_inventory_unknown_test.go @@ -0,0 +1,103 @@ +package lifecycle + +import ( + "context" + "errors" + "testing" + "time" + + agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/task/models" +) + +// TestManagerStartUnreadableRecoveryInventoryStopsNothing pins that a failed +// read of the live standalone recovery-inventory records is treated as an +// UNKNOWN record set, never an empty one. +// +// AC-EXECUTORS-SURVIVAL-002.6 stops a live instance because it is known to +// have no record. Correlation cannot tell an empty inventory from an unread +// one, so handing it the empty slice a failed read returns makes every live +// instance look record-less and sends all of them down that orphan-stop path: +// one transient database error during startup would stop every agent that had +// just survived the restart. The outcome required here is the one +// AC-EXECUTORS-SURVIVAL-002.12 sets for the mirror failure -- recover +// nothing, stop nothing, leave every record to the existing repair path. +func TestManagerStartUnreadableRecoveryInventoryStopsNothing(t *testing.T) { + control := newStandaloneControlServer(t, true) + // Two live instances that WOULD each be stopped as a record-less orphan + // if an unreadable inventory were passed on as an empty one. + control.listInstances = []*agentctlclient.InstanceInfo{ + {ID: "instance-1", SessionID: "session-1"}, + {ID: "instance-2", SessionID: "session-2"}, + } + + mgr := newLivenessTestManager(t, control) + t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) + mgr.SetExecutorRunningWriter(&listingWriter{err: errors.New("database unavailable")}) + mgr.SetRecoveryDeadline(5 * time.Second) + + if err := mgr.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v, want an unreadable inventory to be survivable at startup", err) + } + + control.mu.Lock() + defer control.mu.Unlock() + if len(control.deleted) != 0 { + t.Fatalf("deleted = %v, want none: an unreadable recovery inventory must not make live instances look record-less", control.deleted) + } +} + +// TestManagerStartUnreadableRecoveryInventoryRetracksNothing pins the other +// half of the same outcome: nothing is re-tracked either. Re-tracking off an +// unknown inventory would publish sessions whose identity could not be +// correlated to a record. +func TestManagerStartUnreadableRecoveryInventoryRetracksNothing(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{ + {ID: "instance-1", SessionID: "session-1"}, + } + + mgr := newLivenessTestManager(t, control) + t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) + mgr.SetExecutorRunningWriter(&listingWriter{err: errors.New("database unavailable")}) + mgr.SetRecoveryDeadline(5 * time.Second) + + if err := mgr.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + + if _, ok := mgr.GetExecutionBySessionID("session-1"); ok { + t.Fatal("session-1 was re-tracked from an unreadable recovery inventory; nothing may be correlated to an unknown record set") + } +} + +// TestManagerStartEmptyRecoveryInventoryStillStopsOrphans pins the boundary +// the fix above must not blur: a SUCCESSFUL read that returns no records is +// authoritative, and a live instance really is a record-less orphan then, so +// AC-EXECUTORS-SURVIVAL-002.6 still stops it. Without this, treating an +// unreadable inventory as unknown could be over-applied to an empty one and +// leave genuine orphans running. +func TestManagerStartEmptyRecoveryInventoryStillStopsOrphans(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{ + {ID: "instance-1", SessionID: "session-1"}, + } + + mgr := newLivenessTestManager(t, control) + t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) + mgr.SetExecutorRunningWriter(&listingWriter{rows: []*models.ExecutorRunning{}}) + mgr.SetRecoveryDeadline(5 * time.Second) + + if err := mgr.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + + control.mu.Lock() + defer control.mu.Unlock() + if len(control.deleted) != 1 || control.deleted[0] != "instance-1" { + t.Fatalf("deleted = %v, want [instance-1]: an authoritative empty inventory still makes a live instance an orphan", control.deleted) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_stop_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_stop_test.go new file mode 100644 index 0000000000..a731e9767d --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_stop_test.go @@ -0,0 +1,352 @@ +package lifecycle + +import ( + "context" + "errors" + "testing" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/kandev/kandev/internal/agent/executor" + "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" +) + +// recoveryStoppingExecutor is a minimal ExecutorBackend that also implements +// the optional recoveryStopper capability, so a test can observe which path +// stopUnreconstructableRecoveredInstance took. +type recoveryStoppingExecutor struct { + name executor.Name + stopInstanceCalls int + stopWithRetryCalls []string + stopWithRetryErr error +} + +func (e *recoveryStoppingExecutor) Name() executor.Name { return e.name } +func (e *recoveryStoppingExecutor) HealthCheck(context.Context) error { return nil } +func (e *recoveryStoppingExecutor) CreateInstance(context.Context, *ExecutorCreateRequest) (*ExecutorInstance, error) { + return nil, nil +} +func (e *recoveryStoppingExecutor) StopInstance(context.Context, *ExecutorInstance, bool) error { + e.stopInstanceCalls++ + return nil +} +func (e *recoveryStoppingExecutor) RecoverInstances(context.Context, []*models.ExecutorRunning) ([]*ExecutorInstance, error) { + return nil, nil +} +func (e *recoveryStoppingExecutor) GetInteractiveRunner() *process.InteractiveRunner { return nil } +func (e *recoveryStoppingExecutor) RequiresCloneURL() bool { return false } +func (e *recoveryStoppingExecutor) ShouldApplyPreferredShell() bool { return false } +func (e *recoveryStoppingExecutor) IsAlwaysResumable() bool { return false } +func (e *recoveryStoppingExecutor) stopWithRetry(_ context.Context, instanceID string) error { + e.stopWithRetryCalls = append(e.stopWithRetryCalls, instanceID) + return e.stopWithRetryErr +} + +func newRecoveryStopTestManager(t *testing.T, backend ExecutorBackend) *Manager { + t.Helper() + log := newTestLogger() + execRegistry := NewExecutorRegistry(log) + execRegistry.Register(backend) + mgr := NewManager(newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + return mgr +} + +// TestStopUnreconstructableRecoveredInstanceUsesBoundedRetryWhenSupported +// pins Review round 1 finding 6 (AC-EXECUTORS-SURVIVAL-002.15): a standalone +// backend's bounded-retry stop path must be used for an unreconstructable +// recovered instance, matching the AC-002.6/002.10 loser/orphan stop paths +// in the same package -- not a single unretried StopInstance call. +func TestStopUnreconstructableRecoveredInstanceUsesBoundedRetryWhenSupported(t *testing.T) { + backend := &recoveryStoppingExecutor{name: executor.NameStandalone} + mgr := newRecoveryStopTestManager(t, backend) + + ri := &ExecutorInstance{ + InstanceID: "exec-1", + RuntimeName: executor.NameStandalone, + StandaloneInstanceID: "standalone-1", + } + mgr.stopUnreconstructableRecoveredInstance(context.Background(), ri) + + if len(backend.stopWithRetryCalls) != 1 || backend.stopWithRetryCalls[0] != "standalone-1" { + t.Fatalf("stopWithRetry calls = %v, want exactly one call for standalone-1", backend.stopWithRetryCalls) + } + if backend.stopInstanceCalls != 0 { + t.Fatalf("StopInstance calls = %d, want 0 (bounded-retry path should be used instead)", backend.stopInstanceCalls) + } +} + +// TestStopUnreconstructableRecoveredInstanceLogsRetryExhaustion pins that a +// bounded-retry failure is logged rather than silently dropped +// (AC-EXECUTORS-SURVIVAL-002.4/002.15): the warning must actually be emitted, +// carrying the instance identity and the underlying error, not just leave the +// call completing without panicking and without falling back to StopInstance. +func TestStopUnreconstructableRecoveredInstanceLogsRetryExhaustion(t *testing.T) { + retryErr := errors.New("boom") + backend := &recoveryStoppingExecutor{name: executor.NameStandalone, stopWithRetryErr: retryErr} + core, logs := observer.New(zapcore.WarnLevel) + log, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatal(err) + } + execRegistry := NewExecutorRegistry(log) + execRegistry.Register(backend) + mgr := NewManager(newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + + ri := &ExecutorInstance{ + InstanceID: "exec-1", + RuntimeName: executor.NameStandalone, + StandaloneInstanceID: "standalone-1", + } + mgr.stopUnreconstructableRecoveredInstance(context.Background(), ri) + + if len(backend.stopWithRetryCalls) != 1 { + t.Fatalf("stopWithRetry calls = %d, want 1", len(backend.stopWithRetryCalls)) + } + if backend.stopInstanceCalls != 0 { + t.Fatalf("StopInstance calls = %d, want 0 even when the retry path fails", backend.stopInstanceCalls) + } + + entries := logs.FilterMessage("failed to stop unreconstructable recovered instance after exhausting retries").All() + if len(entries) != 1 { + t.Fatalf("warn entries = %d, want 1", len(entries)) + } + fields := entries[0].ContextMap() + if got := fields["instance_id"]; got != "exec-1" { + t.Fatalf("instance_id = %v, want exec-1", got) + } + if got, ok := fields["error"].(string); !ok || got != retryErr.Error() { + t.Fatalf("error = %v, want %q", fields["error"], retryErr.Error()) + } +} + +// TestStopUnreconstructableRecoveredInstanceFallsBackWithoutRetrySupport +// pins that a backend without the recoveryStopper capability (Docker, +// Sprites, SSH, Kubernetes) keeps using the plain StopInstance call -- +// AC-EXECUTORS-SURVIVAL-002.15's retry contract is standalone-only. +func TestStopUnreconstructableRecoveredInstanceFallsBackWithoutRetrySupport(t *testing.T) { + mock := &guardObservingExecutor{name: executor.NameDocker} + mgr := newRecoveryStopTestManager(t, mock) + + ri := &ExecutorInstance{InstanceID: "exec-1", RuntimeName: executor.NameDocker} + mgr.stopUnreconstructableRecoveredInstance(context.Background(), ri) + + if mock.stopInstanceCalls != 1 { + t.Fatalf("StopInstance calls = %d, want 1 via the plain fallback path", mock.stopInstanceCalls) + } +} + +// TestStopUnreconstructableRecoveredInstanceRetainsGuardOnBoundedRetryFailure +// pins AC-EXECUTORS-SURVIVAL-002.16: when the bounded-retry stop path +// exhausts its retries, the session's recovery guard must be retained for +// the rest of this backend's lifetime -- not left for ReleaseAllExceptRetained +// to release, which would let a fresh launch race the still-live instance. +func TestStopUnreconstructableRecoveredInstanceRetainsGuardOnBoundedRetryFailure(t *testing.T) { + backend := &recoveryStoppingExecutor{name: executor.NameStandalone, stopWithRetryErr: errors.New("boom")} + mgr := newRecoveryStopTestManager(t, backend) + mgr.recoveryGuard.AcquireOrObserve("session-1") + + ri := &ExecutorInstance{ + InstanceID: "exec-1", + SessionID: "session-1", + RuntimeName: executor.NameStandalone, + StandaloneInstanceID: "standalone-1", + } + mgr.stopUnreconstructableRecoveredInstance(context.Background(), ri) + + if err := mgr.recoveryGuard.CheckLaunchAllowed("session-1"); !errors.Is(err, ErrSessionUnstoppableAgent) { + t.Fatalf("CheckLaunchAllowed = %v, want ErrSessionUnstoppableAgent", err) + } + mgr.recoveryGuard.ReleaseAllExceptRetained() + if err := mgr.recoveryGuard.CheckLaunchAllowed("session-1"); !errors.Is(err, ErrSessionUnstoppableAgent) { + t.Fatalf("guard must survive ReleaseAllExceptRetained, got %v", err) + } +} + +// TestStopUnreconstructableRecoveredInstanceReleasesGuardOnBoundedRetrySuccess +// pins the success counterpart: once the bounded-retry stop actually +// succeeds, the session's guard must not be left retained -- a session whose +// live instance really did stop is not the AC-EXECUTORS-SURVIVAL-002.16 case. +func TestStopUnreconstructableRecoveredInstanceReleasesGuardOnBoundedRetrySuccess(t *testing.T) { + backend := &recoveryStoppingExecutor{name: executor.NameStandalone} + mgr := newRecoveryStopTestManager(t, backend) + mgr.recoveryGuard.AcquireOrObserve("session-1") + + ri := &ExecutorInstance{ + InstanceID: "exec-1", + SessionID: "session-1", + RuntimeName: executor.NameStandalone, + StandaloneInstanceID: "standalone-1", + } + mgr.stopUnreconstructableRecoveredInstance(context.Background(), ri) + + if err := mgr.recoveryGuard.CheckLaunchAllowed("session-1"); err != nil { + t.Fatalf("CheckLaunchAllowed = %v, want nil (guard released on stop success)", err) + } +} + +// TestStopUnreconstructableRecoveredInstanceRetainsGuardWhenPlainStopInstanceFails +// pins AC-EXECUTORS-SURVIVAL-002.16 on the non-standalone fallback path too: +// a Docker/Sprites/SSH/Kubernetes instance that could not be stopped via the +// plain StopInstance call must retain its guard exactly like the +// bounded-retry path does. +func TestStopUnreconstructableRecoveredInstanceRetainsGuardWhenPlainStopInstanceFails(t *testing.T) { + mock := &MockExecutor{name: executor.NameDocker, stopInstanceErr: errors.New("boom")} + mgr := newRecoveryStopTestManager(t, mock) + mgr.recoveryGuard.AcquireOrObserve("session-1") + + ri := &ExecutorInstance{InstanceID: "exec-1", SessionID: "session-1", RuntimeName: executor.NameDocker} + mgr.stopUnreconstructableRecoveredInstance(context.Background(), ri) + + if err := mgr.recoveryGuard.CheckLaunchAllowed("session-1"); !errors.Is(err, ErrSessionUnstoppableAgent) { + t.Fatalf("CheckLaunchAllowed = %v, want ErrSessionUnstoppableAgent", err) + } +} + +// TestStopUnreconstructableRecoveredInstanceRetainsGuardWhenNoBackendForRuntime +// pins AC-EXECUTORS-SURVIVAL-002.16 on the defensive no-backend branch: an +// instance whose runtime has no registered backend at all was never even +// attempted to stop, so it must be treated the same as a failed stop -- +// retained, not silently left for a bulk release to clear. +func TestStopUnreconstructableRecoveredInstanceRetainsGuardWhenNoBackendForRuntime(t *testing.T) { + backend := &recoveryStoppingExecutor{name: executor.NameStandalone} + mgr := newRecoveryStopTestManager(t, backend) + mgr.recoveryGuard.AcquireOrObserve("session-1") + + ri := &ExecutorInstance{InstanceID: "exec-1", SessionID: "session-1", RuntimeName: executor.NameDocker} + mgr.stopUnreconstructableRecoveredInstance(context.Background(), ri) + + if err := mgr.recoveryGuard.CheckLaunchAllowed("session-1"); !errors.Is(err, ErrSessionUnstoppableAgent) { + t.Fatalf("CheckLaunchAllowed = %v, want ErrSessionUnstoppableAgent", err) + } +} + +// blockingUnreconstructableStopper is a minimal ExecutorBackend + recoveryStopper +// whose stopWithRetry blocks one named instance until the test releases it, +// so a Manager.Start test can observe whether other recovered records are +// reconstructed while that stop is still in flight. +type blockingUnreconstructableStopper struct { + name executor.Name + recovered []*ExecutorInstance + blockedInst string + started chan struct{} + unblock chan struct{} +} + +func (e *blockingUnreconstructableStopper) Name() executor.Name { return e.name } +func (e *blockingUnreconstructableStopper) HealthCheck(context.Context) error { return nil } +func (e *blockingUnreconstructableStopper) CreateInstance(context.Context, *ExecutorCreateRequest) (*ExecutorInstance, error) { + return nil, nil +} +func (e *blockingUnreconstructableStopper) StopInstance(context.Context, *ExecutorInstance, bool) error { + return nil +} +func (e *blockingUnreconstructableStopper) RecoverInstances(context.Context, []*models.ExecutorRunning) ([]*ExecutorInstance, error) { + return e.recovered, nil +} +func (e *blockingUnreconstructableStopper) GetInteractiveRunner() *process.InteractiveRunner { + return nil +} +func (e *blockingUnreconstructableStopper) RequiresCloneURL() bool { return false } +func (e *blockingUnreconstructableStopper) ShouldApplyPreferredShell() bool { return false } +func (e *blockingUnreconstructableStopper) IsAlwaysResumable() bool { return false } +func (e *blockingUnreconstructableStopper) stopWithRetry(_ context.Context, instanceID string) error { + if instanceID == e.blockedInst { + close(e.started) + <-e.unblock + } + return nil +} + +// TestManagerStartDoesNotBlockOtherRecordsOnOneUnreconstructableStop pins +// AC-EXECUTORS-SURVIVAL-002.11 ("no such instance's outcome shall depend on +// another's"): one recovered record whose not-re-tracked stop is still +// retrying must not prevent a different record in the same pass from being +// fully reconstructed and published. Manager.Start dispatches +// stopUnreconstructableRecoveredInstance on its own goroutine +// (dispatchUnreconstructableStop) rather than blocking the shared consumer +// loop inline. +func TestManagerStartDoesNotBlockOtherRecordsOnOneUnreconstructableStop(t *testing.T) { + log := newTestRegistryLogger() + execRegistry := NewExecutorRegistry(log) + backend := &blockingUnreconstructableStopper{ + name: executor.NameStandalone, + recovered: []*ExecutorInstance{ + { + InstanceID: "exec-blocked", + TaskID: "", // empty TaskID triggers the AC-002.4 refusal path + SessionID: "session-blocked", + RuntimeName: executor.NameStandalone, + StandaloneInstanceID: "standalone-blocked", + }, + { + InstanceID: "exec-fine", + TaskID: "task-fine", + SessionID: "session-fine", + AgentProfileID: recoveryTestAgentProfileID, + RuntimeName: executor.NameStandalone, + StandaloneInstanceID: "standalone-fine", + }, + }, + blockedInst: "standalone-blocked", + started: make(chan struct{}), + unblock: make(chan struct{}), + } + execRegistry.Register(backend) + + mgr := NewManager(newTestRegistry(), &MockEventBus{}, execRegistry, nil, nil, nil, ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + registerRecoveryTestAgentProfile(t, mgr) + mgr.SetExecutorRunningWriter(&listingWriter{rows: []*models.ExecutorRunning{ + {SessionID: "session-blocked"}, + {SessionID: "session-fine"}, + }}) + + done := make(chan error, 1) + go func() { done <- mgr.Start(context.Background()) }() + + select { + case <-backend.started: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the blocked instance's stop to begin") + } + + // session-blocked's stop stays in flight (backend.unblock is not closed + // yet) for this entire poll window: on the pre-fix synchronous dispatch, + // session-fine could never reach the store until after that stop + // resolved, so this loop would exhaust its deadline and fail. It only + // succeeds here because dispatchUnreconstructableStop runs the stop on + // its own goroutine, letting the shared consumer loop reconstruct + // session-fine independently. + deadline := time.Now().Add(2 * time.Second) + for { + if _, ok := mgr.executionStore.GetBySessionID("session-fine"); ok { + break + } + if time.Now().After(deadline) { + t.Fatal("session-fine was not re-tracked while session-blocked's stop was still in flight") + } + time.Sleep(5 * time.Millisecond) + } + + close(backend.unblock) + + select { + case err := <-done: + if err != nil { + t.Fatalf("Start: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for Start to finish after unblocking the stop") + } + + if err := mgr.recoveryGuard.CheckLaunchAllowed("session-blocked"); err != nil { + t.Fatalf("expected session-blocked's guard released once its stop succeeded, got %v", err) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_task_environment_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_task_environment_test.go new file mode 100644 index 0000000000..f0635486b4 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_task_environment_test.go @@ -0,0 +1,133 @@ +package lifecycle + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/agent/executor" +) + +// fakeTaskEnvironmentProvider is a minimal WorkspaceInfoProvider double +// scoped to hydrateRecoveredTaskEnvironmentID's own tests. +type fakeTaskEnvironmentProvider struct { + info *WorkspaceInfo + err error +} + +func (p *fakeTaskEnvironmentProvider) GetWorkspaceInfoForSession(context.Context, string, string) (*WorkspaceInfo, error) { + return p.info, p.err +} + +func (p *fakeTaskEnvironmentProvider) GetWorkspaceInfoForEnvironment(context.Context, string) (*WorkspaceInfo, error) { + return p.info, p.err +} + +// TestHydrateRecoveredTaskEnvironmentIDNilProviderIsANoOp pins that an +// unwired provider (every production backend wires one; only bare-bones test +// doubles don't) is treated as a capability this deployment never had, not a +// failed read -- same convention as every other optional recovery capability. +func TestHydrateRecoveredTaskEnvironmentIDNilProviderIsANoOp(t *testing.T) { + mgr := newTestManager(t) + execution := &AgentExecution{TaskID: "task-1", SessionID: "session-1"} + + if err := mgr.hydrateRecoveredTaskEnvironmentID(context.Background(), execution); err != nil { + t.Fatalf("hydrateRecoveredTaskEnvironmentID: %v, want nil with no provider configured", err) + } + if execution.TaskEnvironmentID != "" { + t.Fatalf("TaskEnvironmentID = %q, want empty", execution.TaskEnvironmentID) + } +} + +// TestHydrateRecoveredTaskEnvironmentIDReturnsErrorOnLookupFailure pins +// Review round 3, finding 4: a lookup failure must propagate as an error so +// the caller can refuse to re-track the instance (AC-EXECUTORS-SURVIVAL-002.13), +// rather than being logged and silently ignored as an earlier version of this +// method did. +func TestHydrateRecoveredTaskEnvironmentIDReturnsErrorOnLookupFailure(t *testing.T) { + mgr := newTestManager(t) + mgr.SetWorkspaceInfoProvider(&fakeTaskEnvironmentProvider{err: errors.New("db unavailable")}) + execution := &AgentExecution{TaskID: "task-1", SessionID: "session-1"} + + if err := mgr.hydrateRecoveredTaskEnvironmentID(context.Background(), execution); err == nil { + t.Fatal("want an error when the lookup itself fails") + } +} + +// TestHydrateRecoveredTaskEnvironmentIDReturnsErrorWhenAnsweredEmpty pins the +// AC-EXECUTORS-SURVIVAL-002.4 half of finding 4: the durable store answering +// successfully with no task-environment identity at all is authoritatively +// absent, not "nothing to hydrate." +func TestHydrateRecoveredTaskEnvironmentIDReturnsErrorWhenAnsweredEmpty(t *testing.T) { + mgr := newTestManager(t) + mgr.SetWorkspaceInfoProvider(&fakeTaskEnvironmentProvider{info: &WorkspaceInfo{TaskEnvironmentID: ""}}) + execution := &AgentExecution{TaskID: "task-1", SessionID: "session-1"} + + if err := mgr.hydrateRecoveredTaskEnvironmentID(context.Background(), execution); err == nil { + t.Fatal("want an error when the durable store answers with no task-environment identity") + } +} + +// TestHydrateRecoveredTaskEnvironmentIDReturnsErrorWhenInfoIsNil covers the +// same authoritatively-absent case for a nil *WorkspaceInfo, not just an +// empty field on a non-nil one. +func TestHydrateRecoveredTaskEnvironmentIDReturnsErrorWhenInfoIsNil(t *testing.T) { + mgr := newTestManager(t) + mgr.SetWorkspaceInfoProvider(&fakeTaskEnvironmentProvider{info: nil, err: nil}) + execution := &AgentExecution{TaskID: "task-1", SessionID: "session-1"} + + if err := mgr.hydrateRecoveredTaskEnvironmentID(context.Background(), execution); err == nil { + t.Fatal("want an error when the durable store answers with a nil info and no error") + } +} + +// TestHydrateRecoveredTaskEnvironmentIDSetsValueOnSuccess pins the happy +// path still works after the fix: a populated answer sets the field and +// returns no error. +func TestHydrateRecoveredTaskEnvironmentIDSetsValueOnSuccess(t *testing.T) { + mgr := newTestManager(t) + mgr.SetWorkspaceInfoProvider(&fakeTaskEnvironmentProvider{info: &WorkspaceInfo{TaskEnvironmentID: "env-1"}}) + execution := &AgentExecution{TaskID: "task-1", SessionID: "session-1"} + + if err := mgr.hydrateRecoveredTaskEnvironmentID(context.Background(), execution); err != nil { + t.Fatalf("hydrateRecoveredTaskEnvironmentID: %v, want nil", err) + } + if execution.TaskEnvironmentID != "env-1" { + t.Fatalf("TaskEnvironmentID = %q, want env-1", execution.TaskEnvironmentID) + } +} + +// TestManagerStartRefusesRecoveredExecutionWhenTaskEnvironmentIdentityCannotBeReconstructed +// pins the full Manager.Start wiring: a recovered instance whose +// task-environment identity cannot be reconstructed (durable lookup fails) +// must never be published as tracked, and is instead routed to the +// AC-EXECUTORS-SURVIVAL-002.6 stop path, exactly like a failed agent-identity +// or turn-outcome reconstruction already is. +func TestManagerStartRefusesRecoveredExecutionWhenTaskEnvironmentIdentityCannotBeReconstructed(t *testing.T) { + log := newTestRegistryLogger() + execRegistry := NewExecutorRegistry(log) + mockExec := &MockExecutor{ + name: executor.NameStandalone, + recoverInstances: []*ExecutorInstance{{ + InstanceID: "exec-bad-env", + TaskID: "task-1", + SessionID: "session-bad-env", + RuntimeName: executor.NameStandalone, + }}, + } + execRegistry.Register(mockExec) + mgr := NewManager(newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, ExecutorFallbackWarn, "", log) + t.Cleanup(func() { _ = mgr.Stop() }) + mgr.SetWorkspaceInfoProvider(&fakeTaskEnvironmentProvider{err: errors.New("db unavailable")}) + + if err := mgr.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + + if _, ok := mgr.GetExecution("exec-bad-env"); ok { + t.Fatal("an execution whose task-environment identity could not be reconstructed must not be tracked") + } + if len(mockExec.stopInstanceCalls) != 1 || mockExec.stopInstanceCalls[0].InstanceID != "exec-bad-env" { + t.Fatalf("stopInstanceCalls = %+v, want the unreconstructable instance stopped", mockExec.stopInstanceCalls) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_turn_outcome.go b/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_turn_outcome.go new file mode 100644 index 0000000000..d178a9bd56 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_turn_outcome.go @@ -0,0 +1,126 @@ +package lifecycle + +import ( + "context" + + "go.uber.org/zap" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/events" +) + +// turnOutcomeApplier is the optional capability satisfied by +// *StandaloneExecutor: only standalone-backed instances have agentctl turn- +// outcome retention (AC-EXECUTORS-SURVIVAL-004) -- this capability covers the +// WORKTREE and LOCAL executor types only, both standalone-runtime. A backend +// that doesn't implement it (Docker, Sprites, SSH, Kubernetes) is out of +// scope, and recovery treats that exactly like "nothing retained." +type turnOutcomeApplier interface { + fetchTurnOutcomeWithRetry(ctx context.Context, instanceID string) (*agentctl.TurnOutcome, error) + ackTurnOutcome(ctx context.Context, instanceID string, turnID int64) error +} + +// recoveredTurnOutcomeResult is the outcome of retrieveRecoveredTurnOutcome: +// exactly one of the three cases AC-EXECUTORS-SURVIVAL-004.5 distinguishes. +type recoveredTurnOutcomeResult int + +const ( + // recoveredTurnOutcomeNone: the read succeeded and nothing was retained, + // or the backend does not support turn-outcome retention at all. Publish + // the session as running. + recoveredTurnOutcomeNone recoveredTurnOutcomeResult = iota + // recoveredTurnOutcomeApplied: an outcome was retrieved and must be applied. + recoveredTurnOutcomeApplied + // recoveredTurnOutcomeReadFailed: the read failed and its retries are + // exhausted. AC-EXECUTORS-SURVIVAL-004.5 forbids publishing running here; + // the instance goes to the not-re-tracked path instead. + recoveredTurnOutcomeReadFailed +) + +// retrieveRecoveredTurnOutcome implements AC-EXECUTORS-SURVIVAL-004.2's read +// step for one recovered instance, bounded by the same per-read timeout and +// retry count as AC-EXECUTORS-SURVIVAL-002.13 (AC-EXECUTORS-SURVIVAL-004.5). +func (m *Manager) retrieveRecoveredTurnOutcome(ctx context.Context, ri *ExecutorInstance) (*agentctl.TurnOutcome, recoveredTurnOutcomeResult) { + backend, err := m.executorRegistry.GetBackend(ri.RuntimeName) + if err != nil { + return nil, recoveredTurnOutcomeNone + } + applier, ok := backend.(turnOutcomeApplier) + if !ok { + return nil, recoveredTurnOutcomeNone + } + outcome, err := applier.fetchTurnOutcomeWithRetry(ctx, ri.StandaloneInstanceID) + if err != nil { + m.logger.Warn("failed to retrieve recovered instance's turn outcome after exhausting retries", + zap.String("instance_id", ri.InstanceID), + zap.String("session_id", ri.SessionID), + zap.Error(err)) + return nil, recoveredTurnOutcomeReadFailed + } + if outcome == nil { + return nil, recoveredTurnOutcomeNone + } + return outcome, recoveredTurnOutcomeApplied +} + +// applyRecoveredTurnOutcome implements AC-EXECUTORS-SURVIVAL-004.2's apply +// step. It seeds the execution's prompt-completion generation guard from the +// retained event's own PromptGeneration -- recreated from zero by recovery, +// per design 03's "Turn outcome across the detached gap" -- so the existing +// completion-claim path in handleAgentEvent (via claimPromptCompletion) +// processes this event exactly as it would have had a backend been attached +// when the turn ended, publishing whatever state that produces. It records +// the applied control-server turn identifier before processing so a live +// redelivery of the same event (agentctl's parked send finally unblocked by +// this stream reconnecting) is recognized and dropped +// (AC-EXECUTORS-SURVIVAL-004.4, see AgentExecution.isRecoveryDuplicateEvent). +// +// The acknowledgement is sent only after handleAgentEvent returns +// (AC-EXECUTORS-SURVIVAL-004.6): on the default in-memory event bus, Publish +// delivers to subscribers synchronously before returning, so the +// orchestrator's session-state and task-state writes are both complete by +// then; on a NATS-backed bus this ordering is best-effort, the same +// documented precedent internal/orchestrator/event_handlers_streaming.go's +// markReadyTurn comment relies on for durable-TurnID-based ordering. A failed +// or best-effort-late acknowledgement is not retried here: the outcome stays +// retained and a later backend re-applies it idempotently +// (AC-EXECUTORS-SURVIVAL-004.6's "applying to a turn that already reached +// that outcome... changes nothing"). +func (m *Manager) applyRecoveredTurnOutcome( + ctx context.Context, execution *AgentExecution, ri *ExecutorInstance, outcome *agentctl.TurnOutcome, +) { + _ = m.executionStore.WithLock(execution.ID, func(current *AgentExecution) { + current.promptGeneration = outcome.Event.PromptGeneration + }) + execution.markRecoveryTurnOutcomeApplied(outcome.TurnID) + + m.handleAgentEvent(execution, outcome.Event) + + backend, err := m.executorRegistry.GetBackend(ri.RuntimeName) + if err != nil { + return + } + applier, ok := backend.(turnOutcomeApplier) + if !ok { + return + } + if err := applier.ackTurnOutcome(ctx, ri.StandaloneInstanceID, outcome.TurnID); err != nil { + m.logger.Warn("failed to acknowledge applied turn outcome; a later backend will re-apply it idempotently", + zap.String("instance_id", ri.InstanceID), + zap.Int64("turn_id", outcome.TurnID), + zap.Error(err)) + } +} + +// publishRecoveredExecutionRunning implements AC-EXECUTORS-SURVIVAL-004.5's +// "nothing retained" case: publish the session as running exactly as an +// ordinary launch's first activity would. +func (m *Manager) publishRecoveredExecutionRunning(ctx context.Context, execution *AgentExecution) { + // Nothing was retained, so any turn still in flight across the restart + // carries a PromptGeneration this freshly reconstructed execution object + // has never seen (its own counter was recreated from zero). Let the first + // live completion adopt it instead of being rejected as superseded -- + // see recoveredPromptGenerationPending. + execution.recoveredPromptGenerationPending.Store(true) + m.eventPublisher.PublishAgentEvent(ctx, events.AgentRunning, execution) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_turn_outcome_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_turn_outcome_test.go new file mode 100644 index 0000000000..a9c7d86c36 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_turn_outcome_test.go @@ -0,0 +1,388 @@ +package lifecycle + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/agent/executor" + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +// fakeTurnOutcomeBackend embeds *MockExecutor so it satisfies ExecutorBackend +// via the existing mock, and additionally implements the unexported +// turnOutcomeApplier capability so tests can drive +// Manager.retrieveRecoveredTurnOutcome/applyRecoveredTurnOutcome without a +// real agentctl control server. +type fakeTurnOutcomeBackend struct { + *MockExecutor + outcome *agentctl.TurnOutcome + fetchErr error + ackErr error + + // eventBus, when set by the test, lets ackTurnOutcome record how many + // events had already been published at ack time -- a monotonic counter + // proving AC-EXECUTORS-SURVIVAL-004.6's apply-before-ack ordering rather + // than merely that both happened. + eventBus *MockEventBus + + fetchCalls []string + ackedCalls []ackedTurnOutcome + publishedCountsAtAck []int +} + +func (f *fakeTurnOutcomeBackend) fetchTurnOutcomeWithRetry(_ context.Context, instanceID string) (*agentctl.TurnOutcome, error) { + f.fetchCalls = append(f.fetchCalls, instanceID) + return f.outcome, f.fetchErr +} + +func (f *fakeTurnOutcomeBackend) ackTurnOutcome(_ context.Context, instanceID string, turnID int64) error { + f.ackedCalls = append(f.ackedCalls, ackedTurnOutcome{instanceID: instanceID, turnID: turnID}) + if f.eventBus != nil { + f.publishedCountsAtAck = append(f.publishedCountsAtAck, len(f.eventBus.PublishedEvents)) + } + return f.ackErr +} + +func newTurnOutcomeTestManager(t *testing.T, backend *fakeTurnOutcomeBackend) (*Manager, *MockEventBus) { + t.Helper() + log := newTestLogger() + registry := NewExecutorRegistry(log) + registry.Register(backend) + eventBus := &MockEventBus{} + mgr := NewManager(newTestRegistry(), eventBus, registry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + return mgr, eventBus +} + +func hasEventType(published []*bus.Event, eventType string) bool { + for _, evt := range published { + if evt.Type == eventType { + return true + } + } + return false +} + +// TestRetrieveRecoveredTurnOutcomeCases pins AC-EXECUTORS-SURVIVAL-004.5's +// three-way result, plus the out-of-scope backend case (a runtime that never +// implements turn-outcome retention, e.g. Docker/Sprites/SSH/Kubernetes, +// which this capability does not cover at all). +func TestRetrieveRecoveredTurnOutcomeCases(t *testing.T) { + t.Run("backend does not implement turnOutcomeApplier", func(t *testing.T) { + mgr, _ := newTurnOutcomeTestManagerPlain(t) + outcome, result := mgr.retrieveRecoveredTurnOutcome(context.Background(), &ExecutorInstance{RuntimeName: executor.NameStandalone}) + if result != recoveredTurnOutcomeNone || outcome != nil { + t.Fatalf("result = %v, outcome = %v, want (recoveredTurnOutcomeNone, nil)", result, outcome) + } + }) + + t.Run("no backend for runtime", func(t *testing.T) { + mgr, _ := newTurnOutcomeTestManagerPlain(t) + outcome, result := mgr.retrieveRecoveredTurnOutcome(context.Background(), &ExecutorInstance{RuntimeName: "unknown"}) + if result != recoveredTurnOutcomeNone || outcome != nil { + t.Fatalf("result = %v, outcome = %v, want (recoveredTurnOutcomeNone, nil)", result, outcome) + } + }) + + t.Run("nothing retained", func(t *testing.T) { + backend := &fakeTurnOutcomeBackend{MockExecutor: &MockExecutor{name: executor.NameStandalone}} + mgr, _ := newTurnOutcomeTestManager(t, backend) + outcome, result := mgr.retrieveRecoveredTurnOutcome(context.Background(), &ExecutorInstance{ + RuntimeName: executor.NameStandalone, StandaloneInstanceID: "inst-1", + }) + if result != recoveredTurnOutcomeNone || outcome != nil { + t.Fatalf("result = %v, outcome = %v, want (recoveredTurnOutcomeNone, nil)", result, outcome) + } + if len(backend.fetchCalls) != 1 || backend.fetchCalls[0] != "inst-1" { + t.Fatalf("fetchCalls = %v, want [inst-1]", backend.fetchCalls) + } + }) + + t.Run("outcome retained", func(t *testing.T) { + want := &agentctl.TurnOutcome{TurnID: 9, Event: streams.AgentEvent{Type: streams.EventTypeComplete}} + backend := &fakeTurnOutcomeBackend{MockExecutor: &MockExecutor{name: executor.NameStandalone}, outcome: want} + mgr, _ := newTurnOutcomeTestManager(t, backend) + outcome, result := mgr.retrieveRecoveredTurnOutcome(context.Background(), &ExecutorInstance{ + RuntimeName: executor.NameStandalone, StandaloneInstanceID: "inst-1", + }) + if result != recoveredTurnOutcomeApplied || outcome != want { + t.Fatalf("result = %v, outcome = %v, want (recoveredTurnOutcomeApplied, %v)", result, outcome, want) + } + }) + + t.Run("read failed", func(t *testing.T) { + backend := &fakeTurnOutcomeBackend{ + MockExecutor: &MockExecutor{name: executor.NameStandalone}, + fetchErr: errors.New("boom"), + } + mgr, _ := newTurnOutcomeTestManager(t, backend) + outcome, result := mgr.retrieveRecoveredTurnOutcome(context.Background(), &ExecutorInstance{ + RuntimeName: executor.NameStandalone, StandaloneInstanceID: "inst-1", + }) + if result != recoveredTurnOutcomeReadFailed || outcome != nil { + t.Fatalf("result = %v, outcome = %v, want (recoveredTurnOutcomeReadFailed, nil)", result, outcome) + } + }) +} + +func newTurnOutcomeTestManagerPlain(t *testing.T) (*Manager, *MockEventBus) { + t.Helper() + log := newTestLogger() + registry := NewExecutorRegistry(log) + registry.Register(&MockExecutor{name: executor.NameStandalone}) + eventBus := &MockEventBus{} + mgr := NewManager(newTestRegistry(), eventBus, registry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + return mgr, eventBus +} + +// TestApplyRecoveredTurnOutcomeAppliesAndAcks pins +// AC-EXECUTORS-SURVIVAL-004.2/004.6: applying a retained outcome publishes +// the state that outcome produces (here, agent.ready from a successful +// completion), seeds the generation guard from the retained event's own +// PromptGeneration, and only then acknowledges it. +func TestApplyRecoveredTurnOutcomeAppliesAndAcks(t *testing.T) { + outcome := &agentctl.TurnOutcome{ + TurnID: 9, + Event: streams.AgentEvent{ + Type: streams.EventTypeComplete, + PromptGeneration: 5, + }, + } + backend := &fakeTurnOutcomeBackend{MockExecutor: &MockExecutor{name: executor.NameStandalone}, outcome: outcome} + mgr, eventBus := newTurnOutcomeTestManager(t, backend) + backend.eventBus = eventBus + execution := createTestExecution("exec-1", "task-1", "session-1") + if err := mgr.executionStore.Add(execution); err != nil { + t.Fatalf("add execution: %v", err) + } + ri := &ExecutorInstance{RuntimeName: executor.NameStandalone, StandaloneInstanceID: "inst-1"} + + mgr.applyRecoveredTurnOutcome(context.Background(), execution, ri, outcome) + + if !hasEventType(eventBus.PublishedEvents, events.AgentReady) { + t.Fatal("expected agent.ready to be published for a successful completion outcome") + } + if len(backend.ackedCalls) != 1 || backend.ackedCalls[0] != (ackedTurnOutcome{instanceID: "inst-1", turnID: 9}) { + t.Fatalf("ackedCalls = %v, want [{inst-1 9}]", backend.ackedCalls) + } + if got := execution.promptGenerationSnapshot(); got != 5 { + t.Fatalf("promptGeneration = %d, want 5 (seeded from the retained event)", got) + } + if !execution.isRecoveryDuplicateEvent(&streams.AgentEvent{ControlTurnID: 9}) { + t.Fatal("expected the applied turn ID (9) to be recorded so a live redelivery is recognized") + } + // AC-EXECUTORS-SURVIVAL-004.6: the outcome must be applied (here, the + // agent.ready publish above) before it is acknowledged -- not merely + // that both eventually happen. The published-event count observed at + // ack time is a monotonic witness of that ordering: it can only be + // nonzero if the apply's publish already ran. + if len(backend.publishedCountsAtAck) != 1 { + t.Fatalf("publishedCountsAtAck = %v, want exactly 1 ack observed", backend.publishedCountsAtAck) + } + if backend.publishedCountsAtAck[0] < 1 { + t.Fatalf("published event count at ack time = %d, want >= 1 (apply's publish must precede ack)", + backend.publishedCountsAtAck[0]) + } +} + +// TestApplyRecoveredTurnOutcomeDedupesLiveRedelivery pins +// AC-EXECUTORS-SURVIVAL-004.4: once an outcome is applied, a subsequent live +// event carrying the same ControlTurnID (agentctl's parked send, finally +// unblocked by the stream reconnecting) must not be re-applied -- no second +// agent.ready. +func TestApplyRecoveredTurnOutcomeDedupesLiveRedelivery(t *testing.T) { + outcome := &agentctl.TurnOutcome{ + TurnID: 9, + Event: streams.AgentEvent{ + Type: streams.EventTypeComplete, + PromptGeneration: 5, + }, + } + backend := &fakeTurnOutcomeBackend{MockExecutor: &MockExecutor{name: executor.NameStandalone}, outcome: outcome} + mgr, eventBus := newTurnOutcomeTestManager(t, backend) + execution := createTestExecution("exec-1", "task-1", "session-1") + if err := mgr.executionStore.Add(execution); err != nil { + t.Fatalf("add execution: %v", err) + } + ri := &ExecutorInstance{RuntimeName: executor.NameStandalone, StandaloneInstanceID: "inst-1"} + mgr.applyRecoveredTurnOutcome(context.Background(), execution, ri, outcome) + readyCountAfterApply := countEventType(eventBus.PublishedEvents, events.AgentReady) + + // The live redelivery: the same event, stamped with the control-server + // turn identifier, as it would arrive once the stream reconnects. + redelivered := outcome.Event + redelivered.ControlTurnID = outcome.TurnID + mgr.handleAgentEvent(execution, redelivered) + + if got := countEventType(eventBus.PublishedEvents, events.AgentReady); got != readyCountAfterApply { + t.Fatalf("agent.ready published %d times after redelivery, want %d (redelivery must be a no-op)", + got, readyCountAfterApply) + } +} + +func countEventType(published []*bus.Event, eventType string) int { + count := 0 + for _, evt := range published { + if evt.Type == eventType { + count++ + } + } + return count +} + +// TestPublishRecoveredExecutionRunningPublishesAgentRunning pins +// AC-EXECUTORS-SURVIVAL-004.5's "nothing retained" case. +func TestPublishRecoveredExecutionRunningPublishesAgentRunning(t *testing.T) { + mgr, eventBus := newTurnOutcomeTestManagerPlain(t) + execution := createTestExecution("exec-1", "task-1", "session-1") + + mgr.publishRecoveredExecutionRunning(context.Background(), execution) + + if !hasEventType(eventBus.PublishedEvents, events.AgentRunning) { + t.Fatal("expected agent.running to be published") + } +} + +// newRecoveryTurnOutcomeExecutorInstance builds the ExecutorInstance a +// Manager.Start recovery pass would hand to the turn-outcome step, wired with +// a resolvable agent profile so it clears the earlier +// reDeriveRecoveredAgentIdentity gate. +func newRecoveryTurnOutcomeExecutorInstance() *ExecutorInstance { + return &ExecutorInstance{ + InstanceID: "exec-recovered", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: recoveryTestAgentProfileID, + RuntimeName: executor.NameStandalone, + StandaloneInstanceID: "std-inst-1", + } +} + +// TestManagerStartAppliesRetainedTurnOutcome pins the full Manager.Start +// wiring for AC-EXECUTORS-SURVIVAL-004.2: a retained outcome is retrieved, +// applied (publishing whatever state it produces), and acknowledged. +func TestManagerStartAppliesRetainedTurnOutcome(t *testing.T) { + log := newTestRegistryLogger() + registry := NewExecutorRegistry(log) + backend := &fakeTurnOutcomeBackend{ + MockExecutor: &MockExecutor{ + name: executor.NameStandalone, + recoverInstances: []*ExecutorInstance{newRecoveryTurnOutcomeExecutorInstance()}, + }, + outcome: &agentctl.TurnOutcome{ + TurnID: 3, + Event: streams.AgentEvent{Type: streams.EventTypeComplete, PromptGeneration: 11}, + }, + } + registry.Register(backend) + eventBus := &MockEventBus{} + mgr := NewManager(newTestRegistry(), eventBus, registry, nil, nil, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) + + if err := mgr.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + + execution, ok := mgr.GetExecutionBySessionID("session-1") + if !ok { + t.Fatal("expected the recovered session to be tracked") + } + // The final published state is agent.ready, matching "whatever state + // that outcome produces" (AC-EXECUTORS-SURVIVAL-004.2). An agent.running + // may also be published ahead of it: this execution's firstActivityOnce + // has never fired, so claimPromptCompletion treats this first-ever + // completion exactly as it would for a live first turn, publishing + // Running before Ready -- that is ordinary completion semantics, not the + // recovery-specific "nothing retained" case this test is not exercising. + if !hasEventType(eventBus.PublishedEvents, events.AgentReady) { + t.Fatal("expected agent.ready to be published for the retained completion outcome") + } + if got := execution.promptGenerationSnapshot(); got != 11 { + t.Fatalf("promptGeneration = %d, want 11", got) + } + if len(backend.ackedCalls) != 1 || backend.ackedCalls[0] != (ackedTurnOutcome{instanceID: "std-inst-1", turnID: 3}) { + t.Fatalf("ackedCalls = %v, want [{std-inst-1 3}]", backend.ackedCalls) + } +} + +// TestManagerStartPublishesRunningWhenNoTurnOutcomeRetained pins +// AC-EXECUTORS-SURVIVAL-004.5's "nothing retained" case through the full +// Manager.Start wiring. +func TestManagerStartPublishesRunningWhenNoTurnOutcomeRetained(t *testing.T) { + log := newTestRegistryLogger() + registry := NewExecutorRegistry(log) + backend := &fakeTurnOutcomeBackend{ + MockExecutor: &MockExecutor{ + name: executor.NameStandalone, + recoverInstances: []*ExecutorInstance{newRecoveryTurnOutcomeExecutorInstance()}, + }, + } + registry.Register(backend) + eventBus := &MockEventBus{} + mgr := NewManager(newTestRegistry(), eventBus, registry, nil, nil, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) + + if err := mgr.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + + if _, ok := mgr.GetExecutionBySessionID("session-1"); !ok { + t.Fatal("expected the recovered session to be tracked") + } + if !hasEventType(eventBus.PublishedEvents, events.AgentRunning) { + t.Fatal("expected agent.running to be published when nothing was retained") + } + if len(backend.ackedCalls) != 0 { + t.Fatalf("ackedCalls = %v, want none: nothing was retained, so nothing to acknowledge", backend.ackedCalls) + } +} + +// TestManagerStartRefusesAndStopsRecoveredExecutionWhenTurnOutcomeCannotBeRetrieved +// pins AC-EXECUTORS-SURVIVAL-004.5's read-failed case: the instance must not +// be re-tracked or published as running -- it takes the same not-re-tracked +// stop path as an unreconstructable agent identity. +func TestManagerStartRefusesAndStopsRecoveredExecutionWhenTurnOutcomeCannotBeRetrieved(t *testing.T) { + log := newTestRegistryLogger() + registry := NewExecutorRegistry(log) + backend := &fakeTurnOutcomeBackend{ + MockExecutor: &MockExecutor{ + name: executor.NameStandalone, + recoverInstances: []*ExecutorInstance{newRecoveryTurnOutcomeExecutorInstance()}, + }, + fetchErr: errors.New("control server unreachable"), + } + registry.Register(backend) + eventBus := &MockEventBus{} + mgr := NewManager(newTestRegistry(), eventBus, registry, nil, nil, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) + + if err := mgr.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + + if _, ok := mgr.GetExecutionBySessionID("session-1"); ok { + t.Fatal("expected the session NOT to be tracked when its turn outcome could not be retrieved") + } + if hasEventType(eventBus.PublishedEvents, events.AgentRunning) { + t.Fatal("expected agent.running NOT to be published: a read failure is not 'nothing retained'") + } + if len(backend.stopInstanceCalls) != 1 || backend.stopInstanceCalls[0].InstanceID != "exec-recovered" { + t.Fatalf("stopInstanceCalls = %v, want exactly one call for exec-recovered", backend.stopInstanceCalls) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_startup_recovery_guard_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_startup_recovery_guard_test.go new file mode 100644 index 0000000000..1c5d02c0da --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_startup_recovery_guard_test.go @@ -0,0 +1,119 @@ +package lifecycle + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +func alwaysNotPassthrough(context.Context, string) (bool, bool) { return false, true } + +// TestTakeStartupRecoveryGuardsGuardsLiveNonPassthroughSessions pins Review +// round 3, finding 1 / AC-EXECUTORS-SURVIVAL-002.8: the returned guard must +// already hold every live standalone recovery-inventory session before +// backend startup composition contacts any control server (adoption +// included), not just by the time Manager.Start gets around to it. +func TestTakeStartupRecoveryGuardsGuardsLiveNonPassthroughSessions(t *testing.T) { + writer := &listingWriter{rows: []*models.ExecutorRunning{ + {SessionID: "session-1"}, {SessionID: "session-2"}, + }} + + guard := TakeStartupRecoveryGuards(context.Background(), writer, alwaysNotPassthrough, newTestLogger()) + + if err := guard.CheckLaunchAllowed("session-1"); !errors.Is(err, ErrSessionRecoveryGuarded) { + t.Fatalf("session-1 CheckLaunchAllowed = %v, want ErrSessionRecoveryGuarded", err) + } + if err := guard.CheckLaunchAllowed("session-2"); !errors.Is(err, ErrSessionRecoveryGuarded) { + t.Fatalf("session-2 CheckLaunchAllowed = %v, want ErrSessionRecoveryGuarded", err) + } +} + +// TestTakeStartupRecoveryGuardsExcludesConfirmedPassthroughSessions pins +// AC-EXECUTORS-SURVIVAL-005.3's exclusion still applying at this earlier +// guard-taking point. +func TestTakeStartupRecoveryGuardsExcludesConfirmedPassthroughSessions(t *testing.T) { + writer := &listingWriter{rows: []*models.ExecutorRunning{{SessionID: "session-passthrough"}}} + isPassthrough := func(context.Context, string) (bool, bool) { return true, true } + + guard := TakeStartupRecoveryGuards(context.Background(), writer, isPassthrough, newTestLogger()) + + if err := guard.CheckLaunchAllowed("session-passthrough"); err != nil { + t.Fatalf("CheckLaunchAllowed = %v, want nil (excluded)", err) + } +} + +// TestTakeStartupRecoveryGuardsGuardsWhenPassthroughLookupFails pins the +// fail-closed default: a lookup that could not read the durable record +// guards the session rather than excluding it. +func TestTakeStartupRecoveryGuardsGuardsWhenPassthroughLookupFails(t *testing.T) { + writer := &listingWriter{rows: []*models.ExecutorRunning{{SessionID: "session-unknown"}}} + failedLookup := func(context.Context, string) (bool, bool) { return false, false } + + guard := TakeStartupRecoveryGuards(context.Background(), writer, failedLookup, newTestLogger()) + + if err := guard.CheckLaunchAllowed("session-unknown"); !errors.Is(err, ErrSessionRecoveryGuarded) { + t.Fatalf("CheckLaunchAllowed = %v, want ErrSessionRecoveryGuarded", err) + } +} + +// TestTakeStartupRecoveryGuardsReturnsEmptyGuardOnListError pins the +// best-effort fallback already established for +// ListLiveStandaloneExecutorsRunning: a failed inventory read yields an +// empty (but usable) guard rather than a nil one or a panic. +func TestTakeStartupRecoveryGuardsReturnsEmptyGuardOnListError(t *testing.T) { + writer := &listingWriter{err: errors.New("db unavailable")} + + guard := TakeStartupRecoveryGuards(context.Background(), writer, alwaysNotPassthrough, newTestLogger()) + + if guard == nil { + t.Fatal("guard = nil, want a usable empty guard") + } + if err := guard.CheckLaunchAllowed("session-1"); err != nil { + t.Fatalf("CheckLaunchAllowed = %v, want nil (nothing to guard)", err) + } +} + +// TestTakeStartupRecoveryGuardsAcceptsNilPassthroughLookup pins the same +// nil-lookup fallback Start applies: no lookup configured guards every +// session, same as a failed read. +func TestTakeStartupRecoveryGuardsAcceptsNilPassthroughLookup(t *testing.T) { + writer := &listingWriter{rows: []*models.ExecutorRunning{{SessionID: "session-1"}}} + + guard := TakeStartupRecoveryGuards(context.Background(), writer, nil, newTestLogger()) + + if err := guard.CheckLaunchAllowed("session-1"); !errors.Is(err, ErrSessionRecoveryGuarded) { + t.Fatalf("CheckLaunchAllowed = %v, want ErrSessionRecoveryGuarded", err) + } +} + +// TestSetRecoveryGuardReplacesDefaultGuard pins that a guard populated +// before Start (by TakeStartupRecoveryGuards) is the one Start's own +// (idempotent) guard-taking and the launch-refusal path both observe -- +// not a fresh, still-empty guard the Manager created for itself. +func TestSetRecoveryGuardReplacesDefaultGuard(t *testing.T) { + mgr := newTestManager(t) + pre := NewRecoveryGuard() + pre.AcquireOrObserve("session-pretaken") + + mgr.SetRecoveryGuard(pre) + + if err := mgr.recoveryGuard.CheckLaunchAllowed("session-pretaken"); !errors.Is(err, ErrSessionRecoveryGuarded) { + t.Fatalf("CheckLaunchAllowed = %v, want ErrSessionRecoveryGuarded", err) + } +} + +// TestSetRecoveryGuardIgnoresNil pins that a nil guard leaves the manager's +// existing guard (its own default, or one installed earlier) in place +// instead of leaving the manager without a guard at all. +func TestSetRecoveryGuardIgnoresNil(t *testing.T) { + mgr := newTestManager(t) + existing := mgr.recoveryGuard + + mgr.SetRecoveryGuard(nil) + + if mgr.recoveryGuard != existing { + t.Fatal("SetRecoveryGuard(nil) replaced the manager's existing guard") + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_stop_detach_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_stop_detach_test.go new file mode 100644 index 0000000000..6e24b4153b --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_stop_detach_test.go @@ -0,0 +1,157 @@ +package lifecycle + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/agent/executor" + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +// newDetachTestManager builds a Manager wired with a MockExecutor registered +// under executor.NameStandalone, so tests can assert whether +// StopAgentWithReason's survivable-detach branch (AC-EXECUTORS-SURVIVAL kill +// path #4) actually reaches StopInstance. +func newDetachTestManager(t *testing.T, agentSurvivalEnabled bool) (*Manager, *MockExecutor) { + t.Helper() + log := newTestLogger() + mockExecutor := &MockExecutor{name: executor.NameStandalone} + registry := NewExecutorRegistry(log) + registry.Register(mockExecutor) + eventBus := &MockEventBus{} + mgr := NewManager(newTestRegistry(), eventBus, registry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + mgr.SetAgentSurvivalEnabled(agentSurvivalEnabled) + return mgr, mockExecutor +} + +func newDetachTestExecution(id, sessionID string) *AgentExecution { + return &AgentExecution{ + ID: id, + SessionID: sessionID, + TaskID: "task-" + id, + RuntimeName: executor.NameStandalone, + agentctl: agentctl.NewClient("127.0.0.1", 12345, newTestLogger()), + } +} + +// TestStopAgentWithReasonDetachesOnBackendShutdownWhenSurvivalEnabled pins +// design 01/02's kill-path #4 fix: a graceful backend shutdown with the +// capability enabled must not reach the executor's StopInstance or publish +// events.AgentStopped (which would drive the orchestrator to persist a +// terminal task-session state for a session whose agent is still running) -- +// it only releases this backend's own tracking and stream subscription. +func TestStopAgentWithReasonDetachesOnBackendShutdownWhenSurvivalEnabled(t *testing.T) { + mgr, mockExecutor := newDetachTestManager(t, true) + execution := newDetachTestExecution("exec-detach", "session-detach") + require.NoError(t, mgr.executionStore.Add(execution)) + + err := mgr.StopAgentWithReason(context.Background(), execution.ID, StopReasonBackendShutdown, false) + require.NoError(t, err) + + _, exists := mgr.executionStore.Get(execution.ID) + require.False(t, exists, "detach must remove this backend's own tracking") + require.Empty(t, mockExecutor.stopInstanceCalls, "detach must never call StopInstance; the instance is left running") + + client, release := execution.AcquireAgentCtlClient() + release() + require.Nil(t, client, "detach must release this backend's agentctl stream subscription") + + eventBus := mgr.eventBus.(*MockEventBus) + for _, evt := range eventBus.PublishedEvents { + require.NotEqual(t, events.AgentStopped, evt.Type, + "detach must not publish agent.stopped: it would drive the orchestrator to persist a terminal state for a still-running agent") + } +} + +// TestStopAgentWithReasonTerminatesForNonShutdownReasonEvenWhenSurvivalEnabled +// confirms the detach branch is scoped exactly to StopReasonBackendShutdown: +// every other stop reason -- user stops, rollback cleanup, task deletion -- +// keeps today's terminating semantics even when the capability is enabled. +func TestStopAgentWithReasonTerminatesForNonShutdownReasonEvenWhenSurvivalEnabled(t *testing.T) { + mgr, mockExecutor := newDetachTestManager(t, true) + execution := newDetachTestExecution("exec-terminate", "session-terminate") + require.NoError(t, mgr.executionStore.Add(execution)) + + err := mgr.StopAgentWithReason(context.Background(), execution.ID, StopReasonTaskDeleted, false) + require.NoError(t, err) + + require.Len(t, mockExecutor.stopInstanceCalls, 1, "a non-shutdown reason must still stop the runtime instance") + + eventBus := mgr.eventBus.(*MockEventBus) + require.True(t, hasAgentStoppedEvent(eventBus.PublishedEvents), "a terminating stop must still publish agent.stopped") +} + +// TestStopAgentWithReasonTerminatesOnBackendShutdownWhenSurvivalDisabled +// confirms the zero value (capability disabled) preserves today's behavior: +// backend shutdown terminates every agent exactly as it did before this +// capability existed. +func TestStopAgentWithReasonTerminatesOnBackendShutdownWhenSurvivalDisabled(t *testing.T) { + mgr, mockExecutor := newDetachTestManager(t, false) + execution := newDetachTestExecution("exec-disabled", "session-disabled") + require.NoError(t, mgr.executionStore.Add(execution)) + + err := mgr.StopAgentWithReason(context.Background(), execution.ID, StopReasonBackendShutdown, false) + require.NoError(t, err) + + require.Len(t, mockExecutor.stopInstanceCalls, 1, "capability disabled must keep the terminating stop on backend shutdown") + + eventBus := mgr.eventBus.(*MockEventBus) + require.True(t, hasAgentStoppedEvent(eventBus.PublishedEvents), "a terminating stop must still publish agent.stopped") +} + +func hasAgentStoppedEvent(published []*bus.Event) bool { + for _, evt := range published { + if evt.Type == events.AgentStopped { + return true + } + } + return false +} + +// TestStopAgentWithReasonTerminatesNonStandaloneRuntimeEvenWhenSurvivalEnabled +// pins the scope boundary the capability is documented to have: the +// survivable-detach branch exists only for the standalone (worktree/local) +// runtime this feature covers. `StopAllAgents` calls StopAgentWithReason with +// StopReasonBackendShutdown for every tracked execution regardless of +// runtime, so a Docker/SSH/Sprites/Kubernetes/remote-docker execution must +// still reach the terminating path -- including the real StopInstance call +// and the agent.stopped publish -- even while the capability is globally +// enabled for the installation's standalone sessions. +func TestStopAgentWithReasonTerminatesNonStandaloneRuntimeEvenWhenSurvivalEnabled(t *testing.T) { + log := newTestLogger() + mockExecutor := &MockExecutor{name: executor.NameDocker} + registry := NewExecutorRegistry(log) + registry.Register(mockExecutor) + eventBus := &MockEventBus{} + mgr := NewManager(newTestRegistry(), eventBus, registry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + mgr.SetAgentSurvivalEnabled(true) + + execution := &AgentExecution{ + ID: "exec-docker", + SessionID: "session-docker", + TaskID: "task-exec-docker", + RuntimeName: executor.NameDocker, + } + require.NoError(t, mgr.executionStore.Add(execution)) + + err := mgr.StopAgentWithReason(context.Background(), execution.ID, StopReasonBackendShutdown, false) + require.NoError(t, err) + + require.Len(t, mockExecutor.stopInstanceCalls, 1, + "a non-standalone runtime must still be stopped on backend shutdown even when the survival capability is enabled") + + eventBus2 := mgr.eventBus.(*MockEventBus) + require.True(t, hasAgentStoppedEvent(eventBus2.PublishedEvents), + "a non-standalone runtime's terminating stop must still publish agent.stopped") + + _, exists := mgr.executionStore.Get(execution.ID) + require.False(t, exists, "a terminated (non-detached) execution must be removed from tracking") +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_stop_worktree_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_stop_worktree_test.go new file mode 100644 index 0000000000..e1bd1a2615 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_stop_worktree_test.go @@ -0,0 +1,76 @@ +package lifecycle + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// seedWorktree builds a directory standing in for a session's worktree, with +// one tracked file and one gitignored file, and returns the path plus a +// closure that fails the test if anything under it changed. +func seedWorktree(t *testing.T) (string, func()) { + t.Helper() + root := filepath.Join(t.TempDir(), "worktree") + require.NoError(t, os.MkdirAll(filepath.Join(root, "src"), 0o755)) + + files := map[string]string{ + filepath.Join(root, "src", "main.go"): "package main\n\nfunc main() {}\n", + filepath.Join(root, ".env.local"): "SECRET=value\n", + } + for path, contents := range files { + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + } + + return root, func() { + t.Helper() + info, err := os.Stat(root) + require.NoErrorf(t, err, "the worktree directory is gone after the stop") + require.True(t, info.IsDir(), "the worktree path is no longer a directory") + for path, want := range files { + got, err := os.ReadFile(path) //nolint:gosec // test-owned path under t.TempDir() + require.NoErrorf(t, err, "worktree file %s is gone after the stop", path) + require.Equalf(t, want, string(got), "worktree file %s was modified by the stop", path) + } + } +} + +// TestBackendShutdownLeavesTheWorktreeUntouchedOnBothStopPaths pins +// AC-EXECUTORS-SURVIVAL-001.7 across the branch this capability introduced. +// A worktree holds the user's uncommitted work, so neither outcome of a +// backend shutdown may touch it: not the survivable detach that leaves the +// agent running, and not the terminating stop the same shutdown takes when +// the capability is off. Only an explicit task teardown removes a worktree, +// and a shutdown is not one. +func TestBackendShutdownLeavesTheWorktreeUntouchedOnBothStopPaths(t *testing.T) { + for _, tc := range []struct { + name string + survivalEnabled bool + wantStopInstance bool + }{ + {name: "survivable detach", survivalEnabled: true, wantStopInstance: false}, + {name: "terminating stop", survivalEnabled: false, wantStopInstance: true}, + } { + t.Run(tc.name, func(t *testing.T) { + mgr, mockExecutor := newDetachTestManager(t, tc.survivalEnabled) + worktree, assertUnchanged := seedWorktree(t) + + execution := newDetachTestExecution("exec-worktree", "session-worktree") + execution.WorkspacePath = worktree + require.NoError(t, mgr.executionStore.Add(execution)) + + require.NoError(t, mgr.StopAgentWithReason(t.Context(), execution.ID, StopReasonBackendShutdown, false)) + + // Guards the premise: if the branch under test stopped being + // reached, the worktree assertion below would pass vacuously. + if tc.wantStopInstance { + require.NotEmpty(t, mockExecutor.stopInstanceCalls, "expected the terminating path") + } else { + require.Empty(t, mockExecutor.stopInstanceCalls, "expected the detach path") + } + assertUnchanged() + }) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rescan_guard_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rescan_guard_test.go index 111b82e7f3..aece982bd9 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rescan_guard_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rescan_guard_test.go @@ -3,6 +3,7 @@ package lifecycle import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "sync" @@ -10,9 +11,27 @@ import ( "github.com/stretchr/testify/require" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/executor" ) +// registerRecoveryTestAgentProfile wires a resolvable agent profile onto mgr +// so a recovered execution's agent identity re-derivation +// (AC-EXECUTORS-SURVIVAL-002.14) succeeds. Tests that assert a recovered +// session lands in the store, but don't care about command-building details, +// use this instead of repeating the wiring; the matching fixture's +// AgentProfileID must be recoveryTestAgentProfileID. +const recoveryTestAgentProfileID = "profile-1" + +func registerRecoveryTestAgentProfile(t *testing.T, mgr *Manager) { + t.Helper() + require.NoError(t, mgr.registry.Register(&cliFlagTestAgent{testAgent: testAgent{id: "recovery-test-agent"}})) + mgr.profileResolver = &restartProfileResolver{profile: &AgentProfileInfo{ + ProfileID: recoveryTestAgentProfileID, + AgentName: "recovery-test-agent", + }} +} + type recordedRescan struct { WorkDir string `json:"work_dir"` SourceRoots []string `json:"workspace_source_roots"` @@ -125,19 +144,21 @@ func TestManagerStartRecoversInstancesIntoStore(t *testing.T) { registry.Register(&MockExecutor{ name: executor.NameStandalone, recoverInstances: []*ExecutorInstance{{ - InstanceID: "exec-recovered", - TaskID: "task-1", - SessionID: "session-1", - ContainerID: "container-1", - ContainerIP: "10.0.0.2", - WorkspacePath: "/workspace", - RuntimeName: executor.NameStandalone, + InstanceID: "exec-recovered", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: recoveryTestAgentProfileID, + ContainerID: "container-1", + ContainerIP: "10.0.0.2", + WorkspacePath: "/workspace", + RuntimeName: executor.NameStandalone, }}, }) mgr := NewManager(newTestRegistry(), &MockEventBus{}, registry, nil, nil, nil, ExecutorFallbackWarn, "", log) cleanupManagerStopCh(t, mgr) t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) require.NoError(t, mgr.Start(context.Background())) @@ -148,6 +169,330 @@ func TestManagerStartRecoversInstancesIntoStore(t *testing.T) { require.Equal(t, "/workspace", execution.WorkspacePath) } +// TestManagerStartRecoveredExecutionCarriesEnvAndRunID pins +// AC-EXECUTORS-SURVIVAL-002.14's "runtime environment" and "run identity" +// reconstruction rows: both are sourced from the adopted instance's own +// environment (never the database), with run identity re-derived from its +// KANDEV_RUN_ID key. +func TestManagerStartRecoveredExecutionCarriesEnvAndRunID(t *testing.T) { + log := newTestRegistryLogger() + registry := NewExecutorRegistry(log) + registry.Register(&MockExecutor{ + name: executor.NameStandalone, + recoverInstances: []*ExecutorInstance{{ + InstanceID: "exec-recovered", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: recoveryTestAgentProfileID, + RuntimeName: executor.NameStandalone, + Env: map[string]string{"KANDEV_RUN_ID": "run-42", "PATH": "/usr/bin"}, + }}, + }) + mgr := NewManager(newTestRegistry(), &MockEventBus{}, registry, nil, nil, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) + + require.NoError(t, mgr.Start(context.Background())) + + execution, ok := mgr.GetExecutionBySessionID("session-1") + require.True(t, ok) + require.Equal(t, "run-42", execution.RunID) + require.Equal(t, map[string]string{"KANDEV_RUN_ID": "run-42", "PATH": "/usr/bin"}, execution.RuntimeEnvironment()) +} + +// TestManagerStartRecoveredExecutionCarriesTaskEnvironmentID pins +// AC-EXECUTORS-SURVIVAL-002.14's "task-environment identity" reconstruction +// row: sourced from the durable store via the session's own task-environment +// reference (WorkspaceInfoProvider), never re-derived from the adopted +// instance, which carries no such identity at all. +func TestManagerStartRecoveredExecutionCarriesTaskEnvironmentID(t *testing.T) { + log := newTestRegistryLogger() + registry := NewExecutorRegistry(log) + registry.Register(&MockExecutor{ + name: executor.NameStandalone, + recoverInstances: []*ExecutorInstance{{ + InstanceID: "exec-recovered", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: recoveryTestAgentProfileID, + RuntimeName: executor.NameStandalone, + }}, + }) + mgr := NewManager(newTestRegistry(), &MockEventBus{}, registry, nil, nil, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) + mgr.SetWorkspaceInfoProvider(&mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{ + "session-1": {TaskEnvironmentID: "env-9"}, + }}) + + require.NoError(t, mgr.Start(context.Background())) + + execution, ok := mgr.GetExecutionBySessionID("session-1") + require.True(t, ok) + require.Equal(t, "env-9", execution.TaskEnvironmentID) +} + +// TestManagerStartRecoveredExecutionRefusesOnWorkspaceInfoLookupFailure pins +// Review round 3, finding 4: task-environment identity is one of +// AC-EXECUTORS-SURVIVAL-002.3's required reconstructed values, so a +// durable-store read failure during recovery must not be tolerated as a soft +// failure -- AC-EXECUTORS-SURVIVAL-002.13 forbids publishing the session as +// re-tracked when a required read fails, and this instance is instead routed +// to the AC-EXECUTORS-SURVIVAL-002.6 stop path. (An earlier version of this +// test pinned the opposite, buggy behavior.) +func TestManagerStartRecoveredExecutionRefusesOnWorkspaceInfoLookupFailure(t *testing.T) { + log := newTestRegistryLogger() + registry := NewExecutorRegistry(log) + mockExec := &MockExecutor{ + name: executor.NameStandalone, + recoverInstances: []*ExecutorInstance{{ + InstanceID: "exec-recovered", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: recoveryTestAgentProfileID, + RuntimeName: executor.NameStandalone, + }}, + } + registry.Register(mockExec) + mgr := NewManager(newTestRegistry(), &MockEventBus{}, registry, nil, nil, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) + mgr.SetWorkspaceInfoProvider(&mockWorkspaceInfoProvider{err: errors.New("db down")}) + + require.NoError(t, mgr.Start(context.Background())) + + _, ok := mgr.GetExecutionBySessionID("session-1") + require.False(t, ok, "recovery must refuse to re-track when task-environment identity cannot be reconstructed") + require.Len(t, mockExec.stopInstanceCalls, 1) + require.Equal(t, "exec-recovered", mockExec.stopInstanceCalls[0].InstanceID) +} + +// TestManagerStartRecoveredExecutionCarriesOfficeAgentProfileID pins +// AC-EXECUTORS-SURVIVAL-002.14's "Office profile identity" reconstruction +// row: sourced from the recovery-inventory record's own persisted metadata +// (a new key in the existing column), not re-derived or read from the +// instance. +func TestManagerStartRecoveredExecutionCarriesOfficeAgentProfileID(t *testing.T) { + log := newTestRegistryLogger() + registry := NewExecutorRegistry(log) + registry.Register(&MockExecutor{ + name: executor.NameStandalone, + recoverInstances: []*ExecutorInstance{{ + InstanceID: "exec-recovered", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: recoveryTestAgentProfileID, + RuntimeName: executor.NameStandalone, + Metadata: map[string]interface{}{ + MetadataKeyOfficeAgentProfileID: "office-profile-9", + }, + }}, + }) + mgr := NewManager(newTestRegistry(), &MockEventBus{}, registry, nil, nil, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) + + require.NoError(t, mgr.Start(context.Background())) + + execution, ok := mgr.GetExecutionBySessionID("session-1") + require.True(t, ok) + require.Equal(t, "office-profile-9", execution.OfficeAgentProfileID) +} + +// TestManagerStartRecoveredExecutionLeavesOfficeAgentProfileIDEmptyForNonOffice +// pins that an absent metadata key (the common, non-Office case) is a +// legitimately empty value, not an error. +func TestManagerStartRecoveredExecutionLeavesOfficeAgentProfileIDEmptyForNonOffice(t *testing.T) { + log := newTestRegistryLogger() + registry := NewExecutorRegistry(log) + registry.Register(&MockExecutor{ + name: executor.NameStandalone, + recoverInstances: []*ExecutorInstance{{ + InstanceID: "exec-recovered", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: recoveryTestAgentProfileID, + RuntimeName: executor.NameStandalone, + }}, + }) + mgr := NewManager(newTestRegistry(), &MockEventBus{}, registry, nil, nil, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) + + require.NoError(t, mgr.Start(context.Background())) + + execution, ok := mgr.GetExecutionBySessionID("session-1") + require.True(t, ok) + require.Empty(t, execution.OfficeAgentProfileID) +} + +// TestManagerStartRecoveredExecutionCarriesWorkspaceSourceRoots pins +// AC-EXECUTORS-SURVIVAL-002.14's "workspace source roots" reconstruction +// row: sourced from the adopted instance, read back rather than pushed. +func TestManagerStartRecoveredExecutionCarriesWorkspaceSourceRoots(t *testing.T) { + log := newTestRegistryLogger() + registry := NewExecutorRegistry(log) + registry.Register(&MockExecutor{ + name: executor.NameStandalone, + recoverInstances: []*ExecutorInstance{{ + InstanceID: "exec-recovered", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: recoveryTestAgentProfileID, + RuntimeName: executor.NameStandalone, + WorkspaceSourceRoots: []string{"/ws/task-1/backend", "/ws/task-1/frontend"}, + }}, + }) + mgr := NewManager(newTestRegistry(), &MockEventBus{}, registry, nil, nil, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) + + require.NoError(t, mgr.Start(context.Background())) + + execution, ok := mgr.GetExecutionBySessionID("session-1") + require.True(t, ok) + require.Equal(t, []string{"/ws/task-1/backend", "/ws/task-1/frontend"}, execution.WorkspaceSourceRoots) +} + +// TestManagerStartRecoveredExecutionCarriesProviderSessionID pins +// AC-EXECUTORS-SURVIVAL-002.14's "provider session identity" reconstruction +// row: sourced from the adopted instance, which holds the live provider +// session, never from a durable/database value. +func TestManagerStartRecoveredExecutionCarriesProviderSessionID(t *testing.T) { + log := newTestRegistryLogger() + registry := NewExecutorRegistry(log) + registry.Register(&MockExecutor{ + name: executor.NameStandalone, + recoverInstances: []*ExecutorInstance{{ + InstanceID: "exec-recovered", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: recoveryTestAgentProfileID, + RuntimeName: executor.NameStandalone, + ProviderSessionID: "provider-session-9", + }}, + }) + mgr := NewManager(newTestRegistry(), &MockEventBus{}, registry, nil, nil, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) + + require.NoError(t, mgr.Start(context.Background())) + + execution, ok := mgr.GetExecutionBySessionID("session-1") + require.True(t, ok) + require.Equal(t, "provider-session-9", execution.ACPSessionID) +} + +// agentIdentityHistoryTestAgent is a resolvable agent whose runtime config +// opts in to history-context injection, so +// TestManagerStartRecoveredExecutionCarriesAgentIdentityCommandsAndHistorySetting +// can assert historyEnabled alongside the built commands. +type agentIdentityHistoryTestAgent struct { + testAgent +} + +func (a *agentIdentityHistoryTestAgent) BuildCommand(_ agents.CommandOptions) agents.Command { + return agents.Cmd("test-agent-cli", "--acp").Build() +} + +// TestManagerStartRecoveredExecutionCarriesAgentIdentityCommandsAndHistorySetting +// pins AC-EXECUTORS-SURVIVAL-002.14's last reconstruction-table row: agent +// identity, the agent and continuation commands and their arguments, and the +// history setting are re-derived from the restored agent profile and the +// agent-type registry -- the same computation an ordinary launch performs -- +// never read from the adopted instance. +func TestManagerStartRecoveredExecutionCarriesAgentIdentityCommandsAndHistorySetting(t *testing.T) { + log := newTestRegistryLogger() + registry := NewExecutorRegistry(log) + registry.Register(&MockExecutor{ + name: executor.NameStandalone, + recoverInstances: []*ExecutorInstance{{ + InstanceID: "exec-recovered", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: "profile-history", + RuntimeName: executor.NameStandalone, + }}, + }) + mgr := NewManager(newTestRegistry(), &MockEventBus{}, registry, nil, nil, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + t.Cleanup(func() { _ = mgr.Stop() }) + require.NoError(t, mgr.registry.Register(&agentIdentityHistoryTestAgent{ + testAgent: testAgent{ + id: "history-agent", + runtimeConfig: &agents.RuntimeConfig{ + Cmd: agents.NewCommand("test-agent-cli"), + SessionConfig: agents.SessionConfig{HistoryContextInjection: true}, + }, + }, + })) + mgr.profileResolver = &restartProfileResolver{profile: &AgentProfileInfo{ + ProfileID: "profile-history", + AgentName: "history-agent", + }} + + require.NoError(t, mgr.Start(context.Background())) + + execution, ok := mgr.GetExecutionBySessionID("session-1") + require.True(t, ok) + require.Equal(t, "history-agent", execution.AgentID) + require.Equal(t, "test-agent-cli --acp", execution.AgentCommand) + require.Equal(t, []string{"test-agent-cli", "--acp"}, execution.AgentArgs) + require.True(t, execution.historyEnabled, "history-context-injection agent must have historyEnabled restored") +} + +// TestManagerStartRefusesAndStopsRecoveredExecutionWhenAgentIdentityCannotBeReconstructed +// pins AC-EXECUTORS-SURVIVAL-002.4's refusal path for +// AC-EXECUTORS-SURVIVAL-002.14's re-derivation row: when the restored +// AgentProfileID no longer resolves to a registered agent type (declared +// source answered, value not present), the instance is not re-tracked and is +// left to the stop path AC-EXECUTORS-SURVIVAL-002.6 defines. +func TestManagerStartRefusesAndStopsRecoveredExecutionWhenAgentIdentityCannotBeReconstructed(t *testing.T) { + log := newTestRegistryLogger() + registry := NewExecutorRegistry(log) + mockExecutor := &MockExecutor{ + name: executor.NameStandalone, + recoverInstances: []*ExecutorInstance{{ + InstanceID: "exec-unreconstructable", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: "profile-deleted", + RuntimeName: executor.NameStandalone, + }}, + } + registry.Register(mockExecutor) + mgr := NewManager(newTestRegistry(), &MockEventBus{}, registry, nil, nil, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + t.Cleanup(func() { _ = mgr.Stop() }) + // No agent registered under "history-agent"/whatever this profile names, + // and the profile resolver reports genuine failure -- either way, + // getAgentConfigForExecution cannot answer. + mgr.profileResolver = &restartProfileResolver{err: errors.New("profile deleted during outage")} + + require.NoError(t, mgr.Start(context.Background())) + + _, ok := mgr.GetExecutionBySessionID("session-1") + require.False(t, ok, "an execution whose agent identity cannot be reconstructed must not be re-tracked") + require.Len(t, mockExecutor.stopInstanceCalls, 1) + require.Equal(t, "exec-unreconstructable", mockExecutor.stopInstanceCalls[0].InstanceID) +} + func TestManagerStartWithoutRegistryIsNoOp(t *testing.T) { mgr := newTestManager(t) diff --git a/apps/backend/internal/agent/runtime/lifecycle/ownership_renewal.go b/apps/backend/internal/agent/runtime/lifecycle/ownership_renewal.go new file mode 100644 index 0000000000..8f21e0361a --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/ownership_renewal.go @@ -0,0 +1,131 @@ +package lifecycle + +import ( + "context" + "errors" + "sync" + "time" + + "go.uber.org/zap" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/common/logger" +) + +// OwnershipClaimer is the narrow surface the renewal loop drives, satisfied +// by *agentctl.ControlClient. Narrowed to one method so tests can drive the +// loop without a real HTTP round trip. +type OwnershipClaimer interface { + ClaimOwnership(ctx context.Context) error +} + +// OwnershipRenewer periodically issues the ownership-claim operation +// (AC-EXECUTORS-CONTROL-OWNERSHIP-003.8) so the adopted or freshly spawned +// control server never begins an unowned shutdown while this backend is +// running (AC-EXECUTORS-CONTROL-OWNERSHIP-003.2). It does not claim +// immediately on Start: the bootstrap handshake (fresh spawn) or a +// successful credential rotation (adoption) already counted as the first +// renewal at the moment this loop is started, so an immediate claim here +// would be a redundant fourth renewing operation not listed by AC-003.8. +// +// Follows the goroutine-ownership convention (explicit Start/Stop, +// WaitGroup-registered, idempotent both ends, selects on a stop signal +// rather than sleeping) established by internal/integrations/healthpoll. +type OwnershipRenewer struct { + claimer OwnershipClaimer + interval time.Duration + logger *logger.Logger + + mu sync.Mutex + cancel context.CancelFunc + wg sync.WaitGroup + started bool +} + +// NewOwnershipRenewer builds a renewer that issues ClaimOwnership on the +// given interval, which callers must derive via +// ownershipperiod.RenewalInterval so it stays strictly inside a third of +// the resolved unowned period. +func NewOwnershipRenewer(claimer OwnershipClaimer, interval time.Duration, log *logger.Logger) *OwnershipRenewer { + return &OwnershipRenewer{claimer: claimer, interval: interval, logger: log} +} + +// Start launches the background loop. Calling Start more than once without +// Stop is a no-op. +func (r *OwnershipRenewer) Start(ctx context.Context) { + r.mu.Lock() + defer r.mu.Unlock() + if r.started || r.claimer == nil || r.interval <= 0 { + return + } + r.started = true + ctx, r.cancel = context.WithCancel(ctx) + r.wg.Add(1) + go r.loop(ctx) +} + +// Stop cancels the loop and waits for it to drain. Safe to call even if +// Start was never called, and safe to call more than once. +func (r *OwnershipRenewer) Stop() { + r.mu.Lock() + if !r.started { + r.mu.Unlock() + return + } + cancel := r.cancel + r.mu.Unlock() + if cancel != nil { + cancel() + } + r.wg.Wait() + r.mu.Lock() + r.started = false + r.mu.Unlock() +} + +func (r *OwnershipRenewer) loop(ctx context.Context) { + defer r.wg.Done() + ticker := time.NewTicker(r.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := r.claimer.ClaimOwnership(ctx); err != nil { + if errors.Is(err, agentctl.ErrOwnershipCredentialSuperseded) { + // AC-EXECUTORS-CONTROL-OWNERSHIP-002.3: a superseded + // credential means some other party rotated it out from + // under this backend (most likely a second backend that + // adopted the server). Retrying would keep presenting a + // credential that can never succeed again, and this loop + // must not mint or fetch a replacement on its own -- only + // a fresh adoption may do that. Stop instead of looping + // back to the ticker. + r.logger.Error("ownership renewal claim rejected: credential superseded; stopping renewal loop without retry", + zap.Error(err)) + r.stopSelf() + return + } + r.logger.Warn("ownership renewal claim failed; will retry on the next interval", + zap.Error(err)) + } + } + } +} + +// stopSelf marks the renewer stopped from within its own loop goroutine. +// Unlike Stop, it must not call wg.Wait(): the loop goroutine invoking this +// is exactly what wg is waiting on, so waiting here would deadlock. A +// concurrent explicit Stop() call remains safe: it will see r.started +// already false and return immediately, or it will call the (idempotent) +// cancel and then wg.Wait(), which unblocks as soon as this goroutine +// returns. +func (r *OwnershipRenewer) stopSelf() { + r.mu.Lock() + defer r.mu.Unlock() + if r.cancel != nil { + r.cancel() + } + r.started = false +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/ownership_renewal_test.go b/apps/backend/internal/agent/runtime/lifecycle/ownership_renewal_test.go new file mode 100644 index 0000000000..6589af72f4 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/ownership_renewal_test.go @@ -0,0 +1,228 @@ +package lifecycle + +import ( + "context" + "sync" + "testing" + "testing/synctest" + "time" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/common/logger" +) + +// countingClaimer records every ClaimOwnership call, optionally failing the +// first N calls to exercise the retry-on-next-interval path, or returning a +// specific error (nextErr, consumed once) to exercise a distinguished +// rejection such as a superseded credential. +type countingClaimer struct { + mu sync.Mutex + calls int + failNext int + nextErr error +} + +func (c *countingClaimer) ClaimOwnership(_ context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + c.calls++ + if c.nextErr != nil { + err := c.nextErr + c.nextErr = nil + return err + } + if c.failNext > 0 { + c.failNext-- + return context.DeadlineExceeded + } + return nil +} + +func (c *countingClaimer) callCount() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.calls +} + +func TestOwnershipRenewerStartStopIdempotent(t *testing.T) { + r := NewOwnershipRenewer(&countingClaimer{}, time.Second, logger.Default()) + + r.Start(context.Background()) + t.Cleanup(r.Stop) + r.Start(context.Background()) // second start is a no-op + r.Stop() + r.Stop() // second stop is a no-op +} + +func TestOwnershipRenewerStopSafeWithoutStart(t *testing.T) { + r := NewOwnershipRenewer(&countingClaimer{}, time.Second, logger.Default()) + r.Stop() +} + +// TestOwnershipRenewerDoesNotClaimImmediately pins that Start does not issue +// an immediate claim: the caller's rotation/handshake that just happened is +// already the first renewal per AC-EXECUTORS-CONTROL-OWNERSHIP-003.8, so an +// immediate claim here would be a redundant fourth renewing operation. +func TestOwnershipRenewerDoesNotClaimImmediately(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + claimer := &countingClaimer{} + r := NewOwnershipRenewer(claimer, time.Minute, logger.Default()) + r.Start(context.Background()) + t.Cleanup(r.Stop) + + synctest.Wait() + if got := claimer.callCount(); got != 0 { + t.Fatalf("callCount immediately after Start = %d, want 0", got) + } + }) +} + +// TestOwnershipRenewerClaimsOnInterval pins that the loop issues one claim +// per elapsed interval, on the interval computed by the caller. +func TestOwnershipRenewerClaimsOnInterval(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + claimer := &countingClaimer{} + r := NewOwnershipRenewer(claimer, time.Minute, logger.Default()) + r.Start(context.Background()) + t.Cleanup(r.Stop) + + time.Sleep(time.Minute + time.Second) + synctest.Wait() + if got := claimer.callCount(); got != 1 { + t.Fatalf("callCount after one interval = %d, want 1", got) + } + + time.Sleep(time.Minute) + synctest.Wait() + if got := claimer.callCount(); got != 2 { + t.Fatalf("callCount after two intervals = %d, want 2", got) + } + }) +} + +// TestOwnershipRenewerRetriesAfterFailure pins that a failed claim does not +// stop the loop: the next interval issues another attempt. +func TestOwnershipRenewerRetriesAfterFailure(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + claimer := &countingClaimer{failNext: 1} + r := NewOwnershipRenewer(claimer, time.Minute, logger.Default()) + r.Start(context.Background()) + t.Cleanup(r.Stop) + + time.Sleep(time.Minute + time.Second) + synctest.Wait() + if got := claimer.callCount(); got != 1 { + t.Fatalf("callCount after failed interval = %d, want 1", got) + } + + time.Sleep(time.Minute) + synctest.Wait() + if got := claimer.callCount(); got != 2 { + t.Fatalf("callCount after retry interval = %d, want 2", got) + } + }) +} + +// TestOwnershipRenewerStopsWithoutRetryOnSupersededCredential pins +// AC-EXECUTORS-CONTROL-OWNERSHIP-002.3: a superseded-credential rejection +// (distinct from a transient failure) must not be retried on the next +// interval -- unlike TestOwnershipRenewerRetriesAfterFailure, the loop ends +// instead of ticking again. +func TestOwnershipRenewerStopsWithoutRetryOnSupersededCredential(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + claimer := &countingClaimer{nextErr: agentctl.ErrOwnershipCredentialSuperseded} + r := NewOwnershipRenewer(claimer, time.Minute, logger.Default()) + r.Start(context.Background()) + t.Cleanup(r.Stop) + + time.Sleep(time.Minute + time.Second) + synctest.Wait() + if got := claimer.callCount(); got != 1 { + t.Fatalf("callCount after superseded rejection = %d, want 1", got) + } + + time.Sleep(3 * time.Minute) + synctest.Wait() + if got := claimer.callCount(); got != 1 { + t.Fatalf("callCount after further intervals = %d, want 1 (no retry)", got) + } + }) +} + +// TestOwnershipRenewerSupersededCredentialAllowsRestart pins that the loop +// self-stopping on a superseded credential leaves the renewer in a state a +// later Start (after the manager re-adopts and obtains a current +// credential) can reuse -- AC-002.3's "shall not acquire a fresh credential +// without adopting again" is the caller's job, but this renewer must not be +// left stuck "started" forever, which would make a legitimate restart a +// silent no-op. +func TestOwnershipRenewerSupersededCredentialAllowsRestart(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + claimer := &countingClaimer{nextErr: agentctl.ErrOwnershipCredentialSuperseded} + r := NewOwnershipRenewer(claimer, time.Minute, logger.Default()) + r.Start(context.Background()) + + time.Sleep(time.Minute + time.Second) + synctest.Wait() + if got := claimer.callCount(); got != 1 { + t.Fatalf("callCount after superseded rejection = %d, want 1", got) + } + + // Simulate re-adoption obtaining a fresh credential and restarting + // the renewer against it. + r.Start(context.Background()) + t.Cleanup(r.Stop) + + time.Sleep(time.Minute + time.Second) + synctest.Wait() + if got := claimer.callCount(); got != 2 { + t.Fatalf("callCount after restart interval = %d, want 2 (restart took effect)", got) + } + }) +} + +// TestOwnershipRenewerExplicitStopAfterSupersededCredentialIsSafe pins that +// an explicit Stop() call racing with (or arriving after) the loop's own +// self-stop neither panics nor hangs. +func TestOwnershipRenewerExplicitStopAfterSupersededCredentialIsSafe(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + claimer := &countingClaimer{nextErr: agentctl.ErrOwnershipCredentialSuperseded} + r := NewOwnershipRenewer(claimer, time.Minute, logger.Default()) + r.Start(context.Background()) + + time.Sleep(time.Minute + time.Second) + synctest.Wait() + + r.Stop() + r.Stop() // still safe a second time + }) +} + +// TestOwnershipRenewerStopEndsLoop pins that Stop halts further claims. +func TestOwnershipRenewerStopEndsLoop(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + claimer := &countingClaimer{} + r := NewOwnershipRenewer(claimer, time.Minute, logger.Default()) + r.Start(context.Background()) + + time.Sleep(time.Minute + time.Second) + synctest.Wait() + r.Stop() + + time.Sleep(5 * time.Minute) + synctest.Wait() + if got := claimer.callCount(); got != 1 { + t.Fatalf("callCount after Stop = %d, want 1 (no further claims)", got) + } + }) +} + +func TestOwnershipRenewerNoOpWithoutClaimerOrInterval(t *testing.T) { + r := NewOwnershipRenewer(nil, time.Minute, logger.Default()) + r.Start(context.Background()) + r.Stop() + + r2 := NewOwnershipRenewer(&countingClaimer{}, 0, logger.Default()) + r2.Start(context.Background()) + r2.Stop() +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/passthrough_scope.go b/apps/backend/internal/agent/runtime/lifecycle/passthrough_scope.go new file mode 100644 index 0000000000..ee167a40ed --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/passthrough_scope.go @@ -0,0 +1,66 @@ +package lifecycle + +import "github.com/kandev/kandev/internal/task/models" + +// AC-EXECUTORS-SURVIVAL-005.3 (design 02 "Passthrough scope"): a passthrough +// session shall behave as if the capability were disabled for that session. +// The agent runs on a terminal owned by *this* backend process, so it dies +// with the backend no matter what the control server does -- there is nothing +// to survive. The two obligations that follow are implemented here and used +// by the two paths that would otherwise treat such a session as survivable: +// +// - never detached: StopAgentWithReason must take the terminating path for +// a passthrough execution even on a graceful backend shutdown, so the +// runtime instance is stopped, agent.stopped is published, and the +// executors_running row stops claiming a live agent. Detaching instead +// leaves a record asserting a running agent that no later pass repairs +// (AC-EXECUTORS-SURVIVAL-004.5). +// - never re-tracked: a passthrough session still owns a real agentctl +// instance (it is created by CreateInstance like any other execution, or +// promoted from a workspace-only one), so that instance genuinely does +// survive on the detached control server and would correlate to the +// session's recovery-inventory record. Re-tracking it would publish the +// session as running while its PTY agent is gone. + +// isPassthroughExecution reports whether a tracked execution is a passthrough +// (PTY/TUI) one. Mirrors the existing in-memory idiom used elsewhere in this +// package (IsAgentReadyForPrompt, RecoverAgentPromptStream): the flag is set +// at launch and at workspace-execution promotion, and PassthroughProcessID is +// set once the terminal process exists, so either being present is the signal. +// In-memory state is authoritative here because the caller holds a live, +// tracked execution -- unlike startup, where nothing is tracked yet and the +// durable session record is the only source (SessionsToGuard). +func isPassthroughExecution(execution *AgentExecution) bool { + return execution != nil && (execution.IsPassthrough || execution.PassthroughProcessID != "") +} + +// recoverableRecords drops the confirmed-passthrough records from a recovery +// pass's inventory so re-tracking never reaches them. guarded is the set +// SessionsToGuard already computed for this pass -- "every named session minus +// the confirmed-passthrough ones" -- so a session whose passthrough mode could +// not be read stays both guarded and recoverable, which is the fail-safe +// direction AC-EXECUTORS-SURVIVAL-005.3 requires ("when that mode cannot be +// read for a record, the session shall be guarded rather than excluded"). +// +// A record with no session identity is kept rather than dropped: it is not a +// session this pass could have classified either way, and correlation already +// skips it. The only records this removes are the ones confirmed passthrough. +func recoverableRecords(records []*models.ExecutorRunning, guarded []string) []*models.ExecutorRunning { + guardedSet := make(map[string]struct{}, len(guarded)) + for _, sessionID := range guarded { + guardedSet[sessionID] = struct{}{} + } + recoverable := make([]*models.ExecutorRunning, 0, len(records)) + for _, record := range records { + if record == nil { + continue + } + if record.SessionID != "" { + if _, ok := guardedSet[record.SessionID]; !ok { + continue + } + } + recoverable = append(recoverable, record) + } + return recoverable +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/persistence.go b/apps/backend/internal/agent/runtime/lifecycle/persistence.go index 2121fb6e14..21b78e5154 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/persistence.go +++ b/apps/backend/internal/agent/runtime/lifecycle/persistence.go @@ -73,6 +73,12 @@ func buildRunningFromExecution(execution *AgentExecution, prior *models.Executor } metadata := execution.MetadataSnapshot() + if execution.OfficeAgentProfileID != "" { + if metadata == nil { + metadata = make(map[string]interface{}) + } + metadata[MetadataKeyOfficeAgentProfileID] = execution.OfficeAgentProfileID + } running := &models.ExecutorRunning{ ID: execution.SessionID, SessionID: execution.SessionID, @@ -456,6 +462,28 @@ type executorRunningReader interface { GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*models.ExecutorRunning, error) } +// executorRunningLister is the optional read-side used to enumerate the +// startup recovery inventory: every live (non-terminal) executors_running row +// on the standalone control server (worktree/local executors). A writer that +// doesn't implement it has no recovery candidates to offer. +type executorRunningLister interface { + ListExecutorsRunningLiveStandalone(ctx context.Context) ([]*models.ExecutorRunning, error) +} + +// ListLiveStandaloneExecutorsRunning returns the startup recovery inventory: +// every live standalone executors_running row, read at startup step 3 before +// any control-server contact, so the recovery guard can be taken against it +// (discovery H). Best-effort: a writer that doesn't support listing yields no +// candidates rather than an error, matching this file's other optional +// capabilities. +func (m *Manager) ListLiveStandaloneExecutorsRunning(ctx context.Context) ([]*models.ExecutorRunning, error) { + lister, ok := m.runningWriter.(executorRunningLister) + if !ok { + return nil, nil + } + return lister.ListExecutorsRunningLiveStandalone(ctx) +} + type executorRunningCASWriter interface { RepairExecutorRunningDeadIfCurrent( ctx context.Context, diff --git a/apps/backend/internal/agent/runtime/lifecycle/persistence_test.go b/apps/backend/internal/agent/runtime/lifecycle/persistence_test.go index a0eba1acdf..14a3a9ec82 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/persistence_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/persistence_test.go @@ -104,6 +104,27 @@ func TestBuildRunningFromExecutionPreservesPriorExecutorIdentity(t *testing.T) { } } +func TestBuildRunningFromExecutionPersistsOfficeAgentProfileID(t *testing.T) { + running := buildRunningFromExecution(&AgentExecution{ + ID: "exec-1", TaskID: "task-1", SessionID: "session-1", + OfficeAgentProfileID: "office-profile-9", + }, nil) + + if got := running.Metadata[MetadataKeyOfficeAgentProfileID]; got != "office-profile-9" { + t.Fatalf("Metadata[%q] = %v, want office-profile-9", MetadataKeyOfficeAgentProfileID, got) + } +} + +func TestBuildRunningFromExecutionOmitsOfficeAgentProfileIDForNonOfficeLaunch(t *testing.T) { + running := buildRunningFromExecution(&AgentExecution{ + ID: "exec-1", TaskID: "task-1", SessionID: "session-1", + }, nil) + + if _, ok := running.Metadata[MetadataKeyOfficeAgentProfileID]; ok { + t.Fatal("Metadata should not carry an office_agent_profile_id key for a non-Office launch") + } +} + func TestBuildRunningFromExecutionBindsResumeTokenToExecutionProfile(t *testing.T) { prior := &models.ExecutorRunning{ ExecutionProfileID: "codex-profile", diff --git a/apps/backend/internal/agent/runtime/lifecycle/recovery_correlation.go b/apps/backend/internal/agent/runtime/lifecycle/recovery_correlation.go new file mode 100644 index 0000000000..e0004298fc --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/recovery_correlation.go @@ -0,0 +1,103 @@ +package lifecycle + +import ( + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/task/models" +) + +// RecoveryCorrelationResult is the outcome of correlating adopted-server +// instances to recovery-inventory records by session identity +// (AC-EXECUTORS-SURVIVAL-002.1), including the AC-EXECUTORS-SURVIVAL-002.10 +// duplicate tiebreak. +type RecoveryCorrelationResult struct { + // Winners maps a session identity to the single live instance to + // re-track for it. + Winners map[string]*agentctl.InstanceInfo + // ToStop lists every instance that must be stopped: duplicate losers, + // ambiguous-session instances (AC-EXECUTORS-SURVIVAL-002.10), and + // orphans with no matching record (AC-EXECUTORS-SURVIVAL-002.6). + ToStop []*agentctl.InstanceInfo +} + +// CorrelateRecoveryInstances joins enumerated instances to recovery-inventory +// records by the session identity the control server itself reports +// (AC-EXECUTORS-SURVIVAL-002.1): +// +// - Exactly one live instance for a session with a record re-tracks that +// instance whether or not its instance identifier equals the record's +// agent execution identifier (AC-EXECUTORS-SURVIVAL-002.2). +// - More than one live instance for the same session re-tracks only the +// one whose instance identifier equals that session's record's agent +// execution identifier; no match or more than one match re-tracks none +// of them and stops every one (AC-EXECUTORS-SURVIVAL-002.10). +// - A live instance whose session has no record is stopped, because it has +// no session identity to attach it to (AC-EXECUTORS-SURVIVAL-002.6). +// - A record with no live instance is left alone entirely: it is not an +// input this function stops or wins, it is left to the existing +// stale-execution repair path (AC-EXECUTORS-SURVIVAL-002.7). +// +// Records are keyed by session identity, so this function has no dependency +// on iteration or arrival order between sessions (AC-EXECUTORS-SURVIVAL-002.11). +func CorrelateRecoveryInstances( + records []*models.ExecutorRunning, + instances []*agentctl.InstanceInfo, +) *RecoveryCorrelationResult { + recordBySession := make(map[string]*models.ExecutorRunning, len(records)) + for _, rec := range records { + if rec == nil || rec.SessionID == "" { + continue + } + recordBySession[rec.SessionID] = rec + } + + instancesBySession := make(map[string][]*agentctl.InstanceInfo) + for _, inst := range instances { + if inst == nil { + continue + } + instancesBySession[inst.SessionID] = append(instancesBySession[inst.SessionID], inst) + } + + result := &RecoveryCorrelationResult{Winners: make(map[string]*agentctl.InstanceInfo)} + for sessionID, group := range instancesBySession { + record, hasRecord := recordBySession[sessionID] + if !hasRecord { + result.ToStop = append(result.ToStop, group...) + continue + } + if len(group) == 1 { + result.Winners[sessionID] = group[0] + continue + } + result.resolveDuplicates(sessionID, record, group) + } + + return result +} + +// resolveDuplicates applies the AC-EXECUTORS-SURVIVAL-002.10 tiebreak for a +// session with more than one live instance. +func (r *RecoveryCorrelationResult) resolveDuplicates( + sessionID string, + record *models.ExecutorRunning, + group []*agentctl.InstanceInfo, +) { + var matches []*agentctl.InstanceInfo + for _, inst := range group { + if inst.ID == record.AgentExecutionID { + matches = append(matches, inst) + } + } + if len(matches) != 1 { + // No match or an ambiguous match: re-track none, stop every candidate. + r.ToStop = append(r.ToStop, group...) + return + } + winner := matches[0] + r.Winners[sessionID] = winner + for _, inst := range group { + if inst != winner { + r.ToStop = append(r.ToStop, inst) + } + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/recovery_correlation_test.go b/apps/backend/internal/agent/runtime/lifecycle/recovery_correlation_test.go new file mode 100644 index 0000000000..97feb3750b --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/recovery_correlation_test.go @@ -0,0 +1,139 @@ +package lifecycle + +import ( + "testing" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/task/models" +) + +func TestCorrelateRecoveryInstancesSingleInstanceReTracksRegardlessOfIDMatch(t *testing.T) { + records := []*models.ExecutorRunning{ + {SessionID: "session-1", AgentExecutionID: "some-other-id"}, + } + instance := &agentctl.InstanceInfo{ID: "instance-1", SessionID: "session-1"} + + result := CorrelateRecoveryInstances(records, []*agentctl.InstanceInfo{instance}) + + if got := result.Winners["session-1"]; got != instance { + t.Fatalf("Winners[session-1] = %v, want the sole live instance despite ID mismatch", got) + } + if len(result.ToStop) != 0 { + t.Fatalf("ToStop = %v, want empty", result.ToStop) + } +} + +func TestCorrelateRecoveryInstancesOrphanInstanceIsStopped(t *testing.T) { + instance := &agentctl.InstanceInfo{ID: "instance-1", SessionID: "session-no-record"} + + result := CorrelateRecoveryInstances(nil, []*agentctl.InstanceInfo{instance}) + + if len(result.Winners) != 0 { + t.Fatalf("Winners = %v, want empty", result.Winners) + } + if len(result.ToStop) != 1 || result.ToStop[0] != instance { + t.Fatalf("ToStop = %v, want [instance]", result.ToStop) + } +} + +func TestCorrelateRecoveryInstancesRecordWithNoInstanceIsLeftAlone(t *testing.T) { + records := []*models.ExecutorRunning{{SessionID: "session-1", AgentExecutionID: "instance-1"}} + + result := CorrelateRecoveryInstances(records, nil) + + if len(result.Winners) != 0 { + t.Fatalf("Winners = %v, want empty", result.Winners) + } + if len(result.ToStop) != 0 { + t.Fatalf("ToStop = %v, want empty (left to the existing stale-execution repair path)", result.ToStop) + } +} + +// TestCorrelateRecoveryInstancesDuplicateTiebreakExactMatchWins pins +// AC-EXECUTORS-SURVIVAL-002.10: with two live instances for one session, the +// one whose ID matches the record's agent execution identifier wins and the +// other is stopped. +func TestCorrelateRecoveryInstancesDuplicateTiebreakExactMatchWins(t *testing.T) { + records := []*models.ExecutorRunning{{SessionID: "session-1", AgentExecutionID: "winner"}} + winner := &agentctl.InstanceInfo{ID: "winner", SessionID: "session-1"} + loser := &agentctl.InstanceInfo{ID: "loser", SessionID: "session-1"} + + result := CorrelateRecoveryInstances(records, []*agentctl.InstanceInfo{loser, winner}) + + if got := result.Winners["session-1"]; got != winner { + t.Fatalf("Winners[session-1] = %v, want winner", got) + } + if len(result.ToStop) != 1 || result.ToStop[0] != loser { + t.Fatalf("ToStop = %v, want [loser]", result.ToStop) + } +} + +// TestCorrelateRecoveryInstancesDuplicateNoMatchStopsAll pins the "no match" +// branch of AC-EXECUTORS-SURVIVAL-002.10: neither instance's ID equals the +// record's agent execution identifier, so none is re-tracked and both stop. +func TestCorrelateRecoveryInstancesDuplicateNoMatchStopsAll(t *testing.T) { + records := []*models.ExecutorRunning{{SessionID: "session-1", AgentExecutionID: "neither"}} + instanceA := &agentctl.InstanceInfo{ID: "a", SessionID: "session-1"} + instanceB := &agentctl.InstanceInfo{ID: "b", SessionID: "session-1"} + + result := CorrelateRecoveryInstances(records, []*agentctl.InstanceInfo{instanceA, instanceB}) + + if len(result.Winners) != 0 { + t.Fatalf("Winners = %v, want empty", result.Winners) + } + if len(result.ToStop) != 2 { + t.Fatalf("ToStop = %v, want both instances stopped", result.ToStop) + } +} + +// TestCorrelateRecoveryInstancesDuplicateAmbiguousMatchStopsAll pins the +// "more than one match" branch of AC-EXECUTORS-SURVIVAL-002.10: attributing +// the transcript to the wrong instance is worse than a cold resume, so an +// ambiguous match re-tracks nothing. +func TestCorrelateRecoveryInstancesDuplicateAmbiguousMatchStopsAll(t *testing.T) { + records := []*models.ExecutorRunning{{SessionID: "session-1", AgentExecutionID: "dup"}} + instanceA := &agentctl.InstanceInfo{ID: "dup", SessionID: "session-1"} + instanceB := &agentctl.InstanceInfo{ID: "dup", SessionID: "session-1"} + + result := CorrelateRecoveryInstances(records, []*agentctl.InstanceInfo{instanceA, instanceB}) + + if len(result.Winners) != 0 { + t.Fatalf("Winners = %v, want empty (ambiguous match)", result.Winners) + } + if len(result.ToStop) != 2 { + t.Fatalf("ToStop = %v, want both instances stopped", result.ToStop) + } +} + +// TestCorrelateRecoveryInstancesMultipleSessionsAreIndependent pins +// AC-EXECUTORS-SURVIVAL-002.11: session-1's single-instance re-track and +// session-2's ambiguous-duplicate stop-all are decided independently, and +// neither's outcome depends on the other's. +func TestCorrelateRecoveryInstancesMultipleSessionsAreIndependent(t *testing.T) { + records := []*models.ExecutorRunning{ + {SessionID: "session-1", AgentExecutionID: "s1-instance"}, + {SessionID: "session-2", AgentExecutionID: "neither-s2-candidate"}, + } + s1Instance := &agentctl.InstanceInfo{ID: "s1-instance", SessionID: "session-1"} + s2A := &agentctl.InstanceInfo{ID: "s2-a", SessionID: "session-2"} + s2B := &agentctl.InstanceInfo{ID: "s2-b", SessionID: "session-2"} + + result := CorrelateRecoveryInstances(records, []*agentctl.InstanceInfo{s1Instance, s2A, s2B}) + + if got := result.Winners["session-1"]; got != s1Instance { + t.Fatalf("Winners[session-1] = %v, want s1Instance", got) + } + if _, ok := result.Winners["session-2"]; ok { + t.Fatalf("Winners[session-2] should not exist: neither of session-2's two instances matched") + } + if len(result.ToStop) != 2 { + t.Fatalf("ToStop = %v, want both session-2 instances stopped, session-1 untouched", result.ToStop) + } +} + +func TestCorrelateRecoveryInstancesEmptyInputsReturnEmptyResult(t *testing.T) { + result := CorrelateRecoveryInstances(nil, nil) + if len(result.Winners) != 0 || len(result.ToStop) != 0 { + t.Fatalf("CorrelateRecoveryInstances(nil, nil) = %+v, want empty result", result) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/recovery_guard.go b/apps/backend/internal/agent/runtime/lifecycle/recovery_guard.go new file mode 100644 index 0000000000..c9e2a3f61d --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/recovery_guard.go @@ -0,0 +1,230 @@ +package lifecycle + +import ( + "context" + "errors" + "sync" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// ErrSessionRecoveryGuarded is returned when a launch is requested for a +// session whose recovery guard is currently held (AC-EXECUTORS-SURVIVAL-002.8): +// re-tracking for that session may still be in flight, so a concurrent launch +// is refused rather than queued or blocked. Retryable: the guard is released +// either when that session's recovery outcome is published, or (absent the +// AC-EXECUTORS-SURVIVAL-002.16 exception) when the recovery deadline elapses. +var ErrSessionRecoveryGuarded = errors.New("session is guarded pending recovery") + +// ErrSessionUnstoppableAgent is returned when a launch is requested for a +// session whose recovery guard was retained under +// AC-EXECUTORS-SURVIVAL-002.16: at least one live instance for that session +// could not be stopped, so nothing in this backend's remaining lifetime will +// resolve the condition. Distinct from ErrSessionRecoveryGuarded: this is NOT +// retryable within the backend's lifetime -- only a backend restart clears +// it, since guards do not survive one and the next start re-evaluates the +// instance. +var ErrSessionUnstoppableAgent = errors.New("session has an unstoppable agent from a prior launch") + +// RecoveryGuard is the in-memory, per-session acquire-or-observe map of +// AC-EXECUTORS-SURVIVAL-002.8. It exists for exactly one backend process +// lifetime and needs no durable representation. A guard is normally released +// when that session's recovery outcome is published, or in bulk when the +// recovery deadline (AC-EXECUTORS-SURVIVAL-003.7) elapses -- except a session +// retained as unstoppable (AC-EXECUTORS-SURVIVAL-002.16) or one whose stop is +// still in flight at the deadline, both of which ReleaseAllExceptRetained +// leaves held. +// +// Safe for concurrent use. Does not itself decide which sessions to guard at +// startup (see SessionsToGuard) or when the recovery deadline elapses (that +// clock starts at first control-server contact, AC-EXECUTORS-SURVIVAL-003.7, +// and is owned by the caller driving adoption/re-tracking). +type RecoveryGuard struct { + mu sync.Mutex + state map[string]guardState +} + +type guardState int + +const ( + guardHeld guardState = iota + guardStopInFlight + guardRetainedUnstoppable +) + +// NewRecoveryGuard returns an empty guard map. +func NewRecoveryGuard() *RecoveryGuard { + return &RecoveryGuard{state: make(map[string]guardState)} +} + +// AcquireOrObserve takes a guard for sessionID if it is not already guarded. +// Returns true when this call newly acquired the guard (the caller may begin +// re-tracking it); false when the session was already guarded (the caller +// must not begin re-tracking it -- AC-EXECUTORS-SURVIVAL-002.8's "a session +// already guarded ... is observed, not guarded twice"). +// +// Callers that also need to check the in-memory execution store for an +// already-tracked session must fold that check into the same critical +// section as their own call to this method to preserve the AC's atomicity +// requirement; RecoveryGuard alone cannot enforce that across packages. +func (g *RecoveryGuard) AcquireOrObserve(sessionID string) bool { + g.mu.Lock() + defer g.mu.Unlock() + if _, held := g.state[sessionID]; held { + return false + } + g.state[sessionID] = guardHeld + return true +} + +// Release drops a session's guard, normally called once that session's +// recovery outcome has been published. A no-op for a session that isn't +// guarded, or one retained under RetainAsUnstoppable (only a backend restart +// clears that case, per AC-EXECUTORS-SURVIVAL-002.16). +func (g *RecoveryGuard) Release(sessionID string) { + g.mu.Lock() + defer g.mu.Unlock() + if g.state[sessionID] == guardRetainedUnstoppable { + return + } + delete(g.state, sessionID) +} + +// MarkStopInFlight records that a stop for this session's losing/orphaned +// instance is still retrying when the recovery deadline elapses +// (AC-EXECUTORS-SURVIVAL-003.7): ReleaseAllExceptRetained must leave it held +// until the stop resolves, rather than releasing it at the bound. A no-op +// for a session that isn't guarded. +func (g *RecoveryGuard) MarkStopInFlight(sessionID string) { + g.mu.Lock() + defer g.mu.Unlock() + if _, held := g.state[sessionID]; !held { + return + } + g.state[sessionID] = guardStopInFlight +} + +// IsStopInFlight reports whether sessionID's guard is currently in the +// guardStopInFlight state (see MarkStopInFlight): its losing/orphaned +// instance's stop was still retrying when the recovery deadline elapsed and +// has not yet resolved. Consulted by standalone liveness classification +// (design 02 "Persistence": "the snapshot must post-date recovery's own +// stops") so a row whose stop is still in flight when an adopted-server +// enumeration is taken classifies unknown rather than alive, since the +// enumeration is a snapshot that predates the stop's actual completion. +func (g *RecoveryGuard) IsStopInFlight(sessionID string) bool { + g.mu.Lock() + defer g.mu.Unlock() + return g.state[sessionID] == guardStopInFlight +} + +// RetainAsUnstoppable marks sessionID's guard to survive +// ReleaseAllExceptRetained and outlive recovery entirely +// (AC-EXECUTORS-SURVIVAL-002.16): at least one live instance for it could +// not be stopped, so only a backend restart may clear the guard. Idempotent. +func (g *RecoveryGuard) RetainAsUnstoppable(sessionID string) { + g.mu.Lock() + defer g.mu.Unlock() + g.state[sessionID] = guardRetainedUnstoppable +} + +// ReleaseAllExceptRetained releases every guard currently held, except a +// session marked in-flight via MarkStopInFlight (until its stop resolves and +// the caller explicitly calls Release or RetainAsUnstoppable) or retained via +// RetainAsUnstoppable. Called once the recovery deadline +// (AC-EXECUTORS-SURVIVAL-003.7) elapses, so "no guard outlives recovery" +// holds except for those two documented exceptions. +func (g *RecoveryGuard) ReleaseAllExceptRetained() { + g.mu.Lock() + defer g.mu.Unlock() + for sessionID, state := range g.state { + if state == guardHeld { + delete(g.state, sessionID) + } + } +} + +// CheckLaunchAllowed reports whether a launch may proceed for sessionID, +// returning the distinct refusal errors AC-EXECUTORS-SURVIVAL-002.8 (retryable) +// and AC-EXECUTORS-SURVIVAL-002.16 (not retryable) require, or nil when the +// session carries no guard. +func (g *RecoveryGuard) CheckLaunchAllowed(sessionID string) error { + g.mu.Lock() + defer g.mu.Unlock() + state, held := g.state[sessionID] + if !held { + return nil + } + if state == guardRetainedUnstoppable { + return ErrSessionUnstoppableAgent + } + return ErrSessionRecoveryGuarded +} + +// PassthroughLookup reads whether sessionID is a passthrough session from +// its durable record. ok is false when the read failed (not when the +// session is simply not passthrough) -- AC-EXECUTORS-SURVIVAL-005.3 requires +// treating a failed read as "guard it" rather than "exclude it." +type PassthroughLookup func(ctx context.Context, sessionID string) (isPassthrough bool, ok bool) + +// SessionsToGuard applies AC-EXECUTORS-SURVIVAL-005.3's exclusion to the +// live standalone recovery-inventory session identities read at startup step +// 3 (2.1's ListLiveStandaloneExecutorsRunning), returning the subset +// AC-EXECUTORS-SURVIVAL-002.8 requires a guard taken for: every session +// except a confirmed passthrough one. A session whose passthrough mode +// couldn't be read is guarded, not excluded -- failing to guard a +// re-tracking candidate risks two live agents for one session, while a +// wrongly guarded passthrough session only costs one refused launch until +// the deadline elapses. +func SessionsToGuard(ctx context.Context, liveSessionIDs []string, isPassthrough PassthroughLookup) []string { + guarded := make([]string, 0, len(liveSessionIDs)) + for _, sessionID := range liveSessionIDs { + passthrough, ok := isPassthrough(ctx, sessionID) + if ok && passthrough { + continue + } + guarded = append(guarded, sessionID) + } + return guarded +} + +// TakeStartupRecoveryGuards reads the live standalone recovery-inventory +// records and takes a guard for every session AC-EXECUTORS-SURVIVAL-002.8 +// requires one for, exactly as Manager.Start does, but callable before a +// Manager exists at all. Backend startup composition calls this ahead of +// its own control-server adoption attempt (design 02 "Startup" step 3 must +// run before step 4): by the time Start would otherwise take these same +// guards, that adoption contact has already happened. The returned guard is +// meant to be installed via Manager.SetRecoveryGuard before Start runs. +// +// A nil passthroughLookup, or one that fails a given session's read, is +// treated as "guard it" rather than "exclude it" -- the same fail-closed +// default Start applies. A failed inventory read yields an empty guard +// rather than an error, matching ListLiveStandaloneExecutorsRunning's own +// best-effort contract. +func TakeStartupRecoveryGuards( + ctx context.Context, + runningWriter ExecutorRunningWriter, + passthroughLookup PassthroughLookup, + log *logger.Logger, +) *RecoveryGuard { + guard := NewRecoveryGuard() + lister, ok := runningWriter.(executorRunningLister) + if !ok { + return guard + } + records, err := lister.ListExecutorsRunningLiveStandalone(ctx) + if err != nil { + log.Warn("failed to read live standalone recovery-inventory records", zap.Error(err)) + return guard + } + if passthroughLookup == nil { + passthroughLookup = func(context.Context, string) (bool, bool) { return false, false } + } + for _, sessionID := range SessionsToGuard(ctx, sessionIDsFromExecutorRunning(records), passthroughLookup) { + guard.AcquireOrObserve(sessionID) + } + return guard +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/recovery_guard_test.go b/apps/backend/internal/agent/runtime/lifecycle/recovery_guard_test.go new file mode 100644 index 0000000000..537992e09c --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/recovery_guard_test.go @@ -0,0 +1,220 @@ +package lifecycle + +import ( + "context" + "errors" + "sync" + "testing" +) + +func TestRecoveryGuardAcquireOrObserveIsAtomicPerSession(t *testing.T) { + g := NewRecoveryGuard() + + if acquired := g.AcquireOrObserve("session-1"); !acquired { + t.Fatal("first AcquireOrObserve = false, want true (newly acquired)") + } + if acquired := g.AcquireOrObserve("session-1"); acquired { + t.Fatal("second AcquireOrObserve on the same session = true, want false (observed, not guarded twice)") + } + if acquired := g.AcquireOrObserve("session-2"); !acquired { + t.Fatal("AcquireOrObserve on a different session = false, want true") + } +} + +func TestRecoveryGuardReleaseAllowsReacquisition(t *testing.T) { + g := NewRecoveryGuard() + g.AcquireOrObserve("session-1") + g.Release("session-1") + + if acquired := g.AcquireOrObserve("session-1"); !acquired { + t.Fatal("AcquireOrObserve after Release = false, want true") + } +} + +func TestRecoveryGuardReleaseOfUnguardedSessionIsSafe(t *testing.T) { + g := NewRecoveryGuard() + g.Release("never-guarded") // must not panic +} + +func TestRecoveryGuardCheckLaunchAllowedForUnguardedSession(t *testing.T) { + g := NewRecoveryGuard() + if err := g.CheckLaunchAllowed("session-1"); err != nil { + t.Fatalf("CheckLaunchAllowed for an unguarded session = %v, want nil", err) + } +} + +// TestRecoveryGuardCheckLaunchAllowedRefusesWithRetryableReason pins +// AC-EXECUTORS-SURVIVAL-002.8: a guarded session's launch is refused with +// the distinct retryable recovery reason, not queued or blocked. +func TestRecoveryGuardCheckLaunchAllowedRefusesWithRetryableReason(t *testing.T) { + g := NewRecoveryGuard() + g.AcquireOrObserve("session-1") + + err := g.CheckLaunchAllowed("session-1") + if !errors.Is(err, ErrSessionRecoveryGuarded) { + t.Fatalf("CheckLaunchAllowed for a guarded session = %v, want ErrSessionRecoveryGuarded", err) + } +} + +// TestRecoveryGuardRetainAsUnstoppableRefusesNonRetryably pins +// AC-EXECUTORS-SURVIVAL-002.16: a session with an unstoppable instance is +// refused with a distinct, non-retryable reason, and stays that way even +// across a Release call (only a backend restart clears it). +func TestRecoveryGuardRetainAsUnstoppableRefusesNonRetryably(t *testing.T) { + g := NewRecoveryGuard() + g.AcquireOrObserve("session-1") + g.RetainAsUnstoppable("session-1") + + err := g.CheckLaunchAllowed("session-1") + if !errors.Is(err, ErrSessionUnstoppableAgent) { + t.Fatalf("CheckLaunchAllowed for a retained session = %v, want ErrSessionUnstoppableAgent", err) + } + + g.Release("session-1") + err = g.CheckLaunchAllowed("session-1") + if !errors.Is(err, ErrSessionUnstoppableAgent) { + t.Fatalf("CheckLaunchAllowed after Release on a retained session = %v, want ErrSessionUnstoppableAgent (Release must not clear retention)", err) + } +} + +// TestRecoveryGuardReleaseAllExceptRetainedHonoursBothExceptions pins +// AC-EXECUTORS-SURVIVAL-003.7: the deadline-triggered mass release must +// leave a retained-unstoppable session AND a stop-in-flight session held, +// releasing only an ordinary guarded session. +func TestRecoveryGuardReleaseAllExceptRetainedHonoursBothExceptions(t *testing.T) { + g := NewRecoveryGuard() + g.AcquireOrObserve("ordinary") + g.AcquireOrObserve("retained") + g.RetainAsUnstoppable("retained") + g.AcquireOrObserve("in-flight") + g.MarkStopInFlight("in-flight") + + g.ReleaseAllExceptRetained() + + if err := g.CheckLaunchAllowed("ordinary"); err != nil { + t.Fatalf("ordinary session after deadline release = %v, want nil (released)", err) + } + if err := g.CheckLaunchAllowed("retained"); !errors.Is(err, ErrSessionUnstoppableAgent) { + t.Fatalf("retained session after deadline release = %v, want ErrSessionUnstoppableAgent (must stay held)", err) + } + if err := g.CheckLaunchAllowed("in-flight"); !errors.Is(err, ErrSessionRecoveryGuarded) { + t.Fatalf("in-flight session after deadline release = %v, want ErrSessionRecoveryGuarded (must stay held)", err) + } +} + +// TestRecoveryGuardStopInFlightThenReleaseClearsIt pins that once an +// in-flight stop resolves successfully, the caller's own Release call (not +// ReleaseAllExceptRetained, which already ran) is what actually frees it. +func TestRecoveryGuardStopInFlightThenReleaseClearsIt(t *testing.T) { + g := NewRecoveryGuard() + g.AcquireOrObserve("session-1") + g.MarkStopInFlight("session-1") + g.ReleaseAllExceptRetained() // must not release it + + g.Release("session-1") // the stop resolved; caller releases explicitly + + if err := g.CheckLaunchAllowed("session-1"); err != nil { + t.Fatalf("CheckLaunchAllowed after explicit Release = %v, want nil", err) + } +} + +func TestRecoveryGuardMarkStopInFlightOnUnguardedSessionIsSafe(t *testing.T) { + g := NewRecoveryGuard() + g.MarkStopInFlight("never-guarded") // must not panic or guard it + if err := g.CheckLaunchAllowed("never-guarded"); err != nil { + t.Fatalf("CheckLaunchAllowed for a never-guarded session = %v, want nil", err) + } +} + +func TestRecoveryGuardIsStopInFlight(t *testing.T) { + g := NewRecoveryGuard() + + if g.IsStopInFlight("session-1") { + t.Fatal("expected an unguarded session to not report stop-in-flight") + } + + g.AcquireOrObserve("session-1") + if g.IsStopInFlight("session-1") { + t.Fatal("expected a plainly held guard to not report stop-in-flight") + } + + g.MarkStopInFlight("session-1") + if !g.IsStopInFlight("session-1") { + t.Fatal("expected session-1 to report stop-in-flight after MarkStopInFlight") + } + + g.Release("session-1") + if g.IsStopInFlight("session-1") { + t.Fatal("expected stop-in-flight to clear once the guard is released") + } +} + +func TestRecoveryGuardConcurrentAcquireOrObserveIsRace_Free(t *testing.T) { + g := NewRecoveryGuard() + var wg sync.WaitGroup + var acquiredCount int + var mu sync.Mutex + + for range 50 { + wg.Add(1) + go func() { + defer wg.Done() + if g.AcquireOrObserve("shared-session") { + mu.Lock() + acquiredCount++ + mu.Unlock() + } + }() + } + wg.Wait() + + if acquiredCount != 1 { + t.Fatalf("acquiredCount = %d, want exactly 1 across 50 concurrent callers", acquiredCount) + } +} + +func TestSessionsToGuardExcludesConfirmedPassthrough(t *testing.T) { + lookup := func(_ context.Context, sessionID string) (bool, bool) { + return sessionID == "passthrough-session", true + } + + got := SessionsToGuard(context.Background(), []string{"normal-session", "passthrough-session"}, lookup) + + if len(got) != 1 || got[0] != "normal-session" { + t.Fatalf("SessionsToGuard = %v, want [normal-session]", got) + } +} + +// TestSessionsToGuardIncludesOnLookupFailure pins AC-EXECUTORS-SURVIVAL-005.3: +// when passthrough mode can't be read for a record, the session is guarded +// rather than excluded. +func TestSessionsToGuardIncludesOnLookupFailure(t *testing.T) { + lookup := func(_ context.Context, _ string) (bool, bool) { + return false, false // read failed + } + + got := SessionsToGuard(context.Background(), []string{"unreadable-session"}, lookup) + + if len(got) != 1 || got[0] != "unreadable-session" { + t.Fatalf("SessionsToGuard on lookup failure = %v, want [unreadable-session] (guarded, not excluded)", got) + } +} + +func TestSessionsToGuardIncludesOrdinarySessions(t *testing.T) { + lookup := func(_ context.Context, _ string) (bool, bool) { + return false, true + } + + got := SessionsToGuard(context.Background(), []string{"session-1", "session-2"}, lookup) + + if len(got) != 2 { + t.Fatalf("SessionsToGuard = %v, want both sessions included", got) + } +} + +func TestSessionsToGuardEmptyInputReturnsEmpty(t *testing.T) { + got := SessionsToGuard(context.Background(), nil, func(context.Context, string) (bool, bool) { return false, true }) + if len(got) != 0 { + t.Fatalf("SessionsToGuard(nil) = %v, want empty", got) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index 8afa905abb..8d32d76b7a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -99,6 +99,37 @@ type AgentExecution struct { promptTurnID string promptLifecycleMu sync.Mutex + // recoveryAppliedControlTurnID is the control-server-assigned turn + // identifier (streams.AgentEvent.ControlTurnID) of a retained turn + // outcome this execution already applied during re-tracking + // (AC-EXECUTORS-SURVIVAL-004.2). A later live-delivered event carrying + // the same identifier is the redelivery design 03 describes -- the + // backend re-attaching finally unblocks agentctl's parked send of the + // exact same event the recovery loop already fetched and applied via + // GetTurnOutcome -- and must be a no-op (AC-EXECUTORS-SURVIVAL-004.4). + // Zero means "nothing applied via recovery," matching ControlTurnID's + // own "not retained" zero value, so the check in handleAgentEvent is a + // single comparison with no separate presence flag needed. atomic + // because it is set once during recovery (before streams reconnect) but + // read from the stream-processing goroutine handleAgentEvent runs on. + recoveryAppliedControlTurnID atomic.Int64 + + // recoveredPromptGenerationPending is true when this execution was + // reconstructed by recovery with a turn believed still in flight (nothing + // retained per AC-EXECUTORS-SURVIVAL-004.2's outcome peek): + // promptGeneration was recreated from zero along with the rest of this + // execution object, so it cannot match the nonzero PromptGeneration the + // live completion for that pre-restart turn will carry. claimPromptCompletion + // adopts the event's own PromptGeneration as this execution's generation + // the first time this flag is observed true, mirroring what + // applyRecoveredTurnOutcome already does explicitly for a turn that + // completed while the backend was detached -- there is nothing to + // supersede yet, since no prompt has ever been dispatched through this + // object. beginExecutionPrompt clears it the moment a prompt IS + // dispatched through this object, so a late, genuinely-stale completion + // from before that dispatch cannot clobber the newly assigned generation. + recoveredPromptGenerationPending atomic.Bool + // PrepareResult carries the environment preparation result back to the caller // so it can be persisted synchronously before UpdateTaskSession clobbers metadata. PrepareResult *EnvPrepareResult `json:"-"` @@ -392,6 +423,31 @@ func (e *AgentExecution) promptGenerationSnapshot() uint64 { return e.promptGeneration } +// markRecoveryTurnOutcomeApplied records turnID as the control-server- +// assigned identifier of a retained turn outcome just applied during +// re-tracking (AC-EXECUTORS-SURVIVAL-004.2). A zero turnID is a no-op: it +// means nothing was retained, and zero is also ControlTurnID's own +// "not retained" value, so recording it would make every ordinary live +// event with an unset ControlTurnID look like a match. +func (e *AgentExecution) markRecoveryTurnOutcomeApplied(turnID int64) { + if turnID == 0 { + return + } + e.recoveryAppliedControlTurnID.Store(turnID) +} + +// isRecoveryDuplicateEvent reports whether event is a live redelivery of a +// turn outcome this execution already applied from a retained read +// (AC-EXECUTORS-SURVIVAL-004.4). An event with no ControlTurnID stamp (the +// overwhelming majority -- only a terminal event that was ever retained +// while detached carries one) never matches. +func (e *AgentExecution) isRecoveryDuplicateEvent(event *agentctl.AgentEvent) bool { + if event == nil || event.ControlTurnID == 0 { + return false + } + return e.recoveryAppliedControlTurnID.Load() == event.ControlTurnID +} + func (e *AgentExecution) armPromptActivity() { e.lastActivityAtMu.Lock() e.lastActivityAt = time.Now() @@ -1342,6 +1398,14 @@ type WorkspaceInfoProvider interface { GetWorkspaceInfoForEnvironment(ctx context.Context, taskEnvironmentID string) (*WorkspaceInfo, error) } +// PassthroughSessionProvider reads a task session's durable passthrough-mode +// snapshot (TaskSession.IsPassthrough) so backend startup composition can +// build the PassthroughLookup used by SetPassthroughLookup +// (AC-EXECUTORS-SURVIVAL-005.3). +type PassthroughSessionProvider interface { + GetTaskSession(ctx context.Context, sessionID string) (*models.TaskSession, error) +} + // RecoveredExecution contains info about an execution recovered from a runtime. type RecoveredExecution struct { ExecutionID string diff --git a/apps/backend/internal/agent/runtime/lifecycle/types_test.go b/apps/backend/internal/agent/runtime/lifecycle/types_test.go new file mode 100644 index 0000000000..5d61fbe9a1 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/types_test.go @@ -0,0 +1,60 @@ +package lifecycle + +import ( + "testing" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" +) + +// TestIsRecoveryDuplicateEvent pins AC-EXECUTORS-SURVIVAL-004.4's decision +// rule directly on AgentExecution, independent of the Manager-level +// apply/dedup flow covered elsewhere: only an event whose ControlTurnID +// matches the identifier this execution recorded as applied is a duplicate. +func TestIsRecoveryDuplicateEvent(t *testing.T) { + t.Run("no outcome applied yet", func(t *testing.T) { + var e AgentExecution + if e.isRecoveryDuplicateEvent(&agentctl.AgentEvent{ControlTurnID: 9}) { + t.Fatal("expected no match before markRecoveryTurnOutcomeApplied was ever called") + } + }) + + t.Run("nil event", func(t *testing.T) { + var e AgentExecution + e.markRecoveryTurnOutcomeApplied(9) + if e.isRecoveryDuplicateEvent(nil) { + t.Fatal("expected a nil event to never match") + } + }) + + t.Run("event with no ControlTurnID stamp", func(t *testing.T) { + var e AgentExecution + e.markRecoveryTurnOutcomeApplied(9) + if e.isRecoveryDuplicateEvent(&agentctl.AgentEvent{}) { + t.Fatal("expected an unstamped event (ControlTurnID zero) to never match") + } + }) + + t.Run("mismatched turn ID", func(t *testing.T) { + var e AgentExecution + e.markRecoveryTurnOutcomeApplied(9) + if e.isRecoveryDuplicateEvent(&agentctl.AgentEvent{ControlTurnID: 10}) { + t.Fatal("expected a different turn's event to never match") + } + }) + + t.Run("matching turn ID", func(t *testing.T) { + var e AgentExecution + e.markRecoveryTurnOutcomeApplied(9) + if !e.isRecoveryDuplicateEvent(&agentctl.AgentEvent{ControlTurnID: 9}) { + t.Fatal("expected the same turn's redelivered event to match") + } + }) + + t.Run("zero turnID is a no-op", func(t *testing.T) { + var e AgentExecution + e.markRecoveryTurnOutcomeApplied(0) + if e.isRecoveryDuplicateEvent(&agentctl.AgentEvent{ControlTurnID: 0}) { + t.Fatal("expected marking zero to never make an unstamped event match") + } + }) +} diff --git a/apps/backend/internal/agentctl/AGENTS.md b/apps/backend/internal/agentctl/AGENTS.md index 466888392d..81320e275a 100644 --- a/apps/backend/internal/agentctl/AGENTS.md +++ b/apps/backend/internal/agentctl/AGENTS.md @@ -15,6 +15,28 @@ agentctl exposes these route groups (see `server/api/`): - `/workspace/*` - File operations, search, tree - `/vscode/*` - VS Code integration proxy +The **control server** (`server/api/control_server.go`) is a separate router from +the per-instance ones above, and it owns the agent-survival surface: +- `/identity` - opaque per-launch server identity, advertised capability set, + and this server's own resolved unowned period. Unauthenticated, because it is + what decides whether to authenticate. It therefore carries no filesystem path. +- `/ownership/prove` - challenge/response proving this process holds the + credential the caller's record names. Also unauthenticated, for the same + reason, and safe because a proof is a keyed digest over a caller-chosen + challenge and cannot be inverted to the credential. Derivation is shared with + the backend through `internal/common/ownershipproof`. +- `/api/v1/ownership/claim`, `/rotate`, `/confirm`, `/shutdown`, `/details` - + the authenticated ownership operations (`claim` is the renewal an owning + backend repeats on a cadence derived from the unowned period), plus the + filesystem paths kept off `/identity`. + +Auth on that router is `controlCredentialAuth`, which has three tiers rather +than on/off. `/health`, `/auth/handshake`, `/identity`, and `/ownership/prove` +are exempt entirely. The paths in `adoptionOnlyPaths` (`rotate`, `shutdown`) +additionally accept the **superseded** credential until the rotation that +replaced it is confirmed, so a backend that crashed mid-rotation can still +adopt. Every other authenticated route accepts only the current credential. + ## Multi-repository Git and review payloads Preserve the repository-scoped fields `repository_name`, `base_ref`, and diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go index c7c3d28714..c7bede7159 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go @@ -746,17 +746,16 @@ func (a *Adapter) emitSetModelEvent( SessionModels: convertSessionModels(cachedModels), ConfigOptions: outConfig, } - sent := a.sendUpdateLocked(event) - closed := a.closed a.mu.Unlock() a.logger.Info("emitting session_models convergence event after SetModel", zap.String("session_id", sessionID), zap.String("model_id", modelID), ) - if !sent && !closed { - a.logger.Warn("updates channel full, dropping event", zap.String("type", event.Type)) - } + // COVERED site (AC-EXECUTORS-SURVIVAL-001.5/.6): sendUpdate blocks rather + // than drops on a full updatesCh and must run without a.mu held -- see + // emitDialectContextWindow's doc comment for the deadlock this avoids. + a.sendUpdate(event) } // currentModelFromConfig returns the CurrentValue of the model-shaped @@ -1142,12 +1141,11 @@ func (a *Adapter) emitAuthoritativeConfigOptions( } delete(a.contextSamples, sessionID) } - sent := a.sendUpdateLocked(event) - closed := a.closed a.mu.Unlock() - if !sent && !closed { - a.logger.Warn("updates channel full, dropping event", zap.String("type", event.Type)) - } + // COVERED site (AC-EXECUTORS-SURVIVAL-001.5/.6): sendUpdate blocks rather + // than drops on a full updatesCh and must run without a.mu held -- see + // emitDialectContextWindow's doc comment for the deadlock this avoids. + a.sendUpdate(event) } // emitSetConfigOptionEvent emits a session_models convergence event after a @@ -1213,8 +1211,6 @@ func (a *Adapter) emitSetConfigOptionEvent( SessionModels: convertSessionModels(cachedModels), ConfigOptions: outConfig, } - sent := a.sendUpdateLocked(event) - closed := a.closed a.mu.Unlock() a.logger.Info("emitting session_models convergence event after SetConfigOption", @@ -1222,9 +1218,10 @@ func (a *Adapter) emitSetConfigOptionEvent( zap.String("config_id", configID), zap.String("value", value), ) - if !sent && !closed { - a.logger.Warn("updates channel full, dropping event", zap.String("type", event.Type)) - } + // COVERED site (AC-EXECUTORS-SURVIVAL-001.5/.6): sendUpdate blocks rather + // than drops on a full updatesCh and must run without a.mu held -- see + // emitDialectContextWindow's doc comment for the deadlock this avoids. + a.sendUpdate(event) } // isModelConfigID reports whether configID identifies the model-shaped diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_updates.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_updates.go index 4c50cbcfd3..c4e1040b00 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_updates.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_updates.go @@ -290,10 +290,20 @@ func (a *Adapter) observeCodexProviderEvidence(promptGeneration uint64, event *A return capacity && (systemError || wasSystemError) } -// emitDialectContextWindow derives and enqueues an agent-specific context -// sample while holding the same lock that protects the active session/model. -// This keeps a model switch from interleaving between size selection and event -// delivery. +// emitDialectContextWindow derives an agent-specific context sample while +// holding the same lock that protects the active session/model -- this keeps +// a model switch from interleaving between size selection and event +// delivery -- then delivers it after releasing that lock. +// +// This is a COVERED site (AC-EXECUTORS-SURVIVAL-001.5/.6): delivery uses the +// existing sendUpdate helper, which blocks rather than drops when updatesCh is +// full and is cancelable via lifetimeCtx/closedCh on adapter shutdown -- it +// was already built for exactly this purpose (see its doc comment) and is +// already used by sibling sites in this package (e.g. SetMode), so no new +// primitive is needed here. sendUpdate must not be called while a.mu is held: +// Close() acquires a.mu as its first act before cancelling lifetimeCtx, so +// parking under the lock (the old sendUpdateLocked behavior this replaces) +// would deadlock Close against this very send. func (a *Adapter) emitDialectContextWindow(sessionID string, meta map[string]any) *AgentEvent { a.mu.Lock() if a.closed || sessionID != a.sessionID { @@ -314,15 +324,9 @@ func (a *Adapter) emitDialectContextWindow(sessionID string, meta map[string]any ContextWindowRemaining: remaining, ContextEfficiency: float64(sample.used) / float64(sample.size) * 100, } - sent := a.sendUpdateLocked(event) - if sent { - a.contextSamples[sessionID] = sample - } + a.contextSamples[sessionID] = sample a.mu.Unlock() - if !sent { - a.logger.Warn("updates channel full, dropping event", zap.String("type", event.Type)) - return nil - } + a.sendUpdate(event) return &event } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/convergence_event_blocking_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/convergence_event_blocking_test.go new file mode 100644 index 0000000000..123aae6a09 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/convergence_event_blocking_test.go @@ -0,0 +1,125 @@ +package acp + +import ( + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// TestConvergenceEventEmittersParkOnFullChannelThenDeliver pins +// AC-EXECUTORS-SURVIVAL-001.5/.6 for the three session_models convergence +// emitters in adapter_session.go (emitSetModelEvent, emitSetConfigOptionEvent, +// emitAuthoritativeConfigOptions): a full updatesCh must park the call rather +// than silently dropping the convergence event, and release once a consumer +// drains a slot. All three used to call sendUpdateLocked (drop-on-full) while +// holding a.mu; they now build the event under a.mu, release the lock, and +// deliver via the existing sendUpdate helper (see emitDialectContextWindow's +// doc comment for why the lock must be released first). +func TestConvergenceEventEmittersParkOnFullChannelThenDeliver(t *testing.T) { + cachedModels := []modelInfo{{ModelId: "gpt-5", Name: "GPT-5"}} + cachedConfig := []streams.ConfigOption{ + {Type: "select", ID: "model", Category: "model", CurrentValue: "gpt-5"}, + {Type: "select", ID: "reasoning_effort", CurrentValue: "low"}, + } + + tests := []struct { + name string + emit func(a *Adapter) + }{ + { + name: "emitSetModelEvent", + emit: func(a *Adapter) { + a.emitSetModelEvent("sess-1", "gpt-5.6", cachedModels, cachedConfig) + }, + }, + { + name: "emitSetConfigOptionEvent", + emit: func(a *Adapter) { + a.emitSetConfigOptionEvent("sess-1", "reasoning_effort", "high", cachedModels, cachedConfig) + }, + }, + { + name: "emitAuthoritativeConfigOptions", + emit: func(a *Adapter) { + a.emitAuthoritativeConfigOptions("sess-1", "reasoning_effort", nil, cachedModels) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := newTestAdapter() + t.Cleanup(func() { _ = a.Close() }) + a.sessionID = "sess-1" + a.updatesCh = make(chan AgentEvent, 1) + a.updatesCh <- AgentEvent{Type: streams.EventTypeMessageChunk} // fill the buffer + + done := make(chan struct{}) + go func() { + tt.emit(a) + close(done) + }() + + select { + case <-done: + t.Fatal("emitter returned before the channel had room -- it must park, not drop") + case <-time.After(50 * time.Millisecond): + } + + <-a.updatesCh // drain the pre-filled slot, freeing room for the parked send + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("emitter did not return promptly after the channel had room") + } + + select { + case event := <-a.updatesCh: + if event.Type != streams.EventTypeSessionModels { + t.Fatalf("event type = %q, want session_models", event.Type) + } + default: + t.Fatal("the parked convergence event was never delivered") + } + }) + } +} + +// TestConvergenceEventEmittersReleaseOnAdapterClose pins the pause-not-a-hang +// half: a parked convergence emitter must release once the adapter closes, +// without ever delivering the event. +func TestConvergenceEventEmittersReleaseOnAdapterClose(t *testing.T) { + cachedModels := []modelInfo{{ModelId: "gpt-5", Name: "GPT-5"}} + cachedConfig := []streams.ConfigOption{ + {Type: "select", ID: "model", Category: "model", CurrentValue: "gpt-5"}, + } + + a := newTestAdapter() + a.sessionID = "sess-1" + a.updatesCh = make(chan AgentEvent, 1) + a.updatesCh <- AgentEvent{Type: streams.EventTypeMessageChunk} + + done := make(chan struct{}) + go func() { + a.emitSetModelEvent("sess-1", "gpt-5.6", cachedModels, cachedConfig) + close(done) + }() + + select { + case <-done: + t.Fatal("emitter returned before the channel had room or the adapter closed") + case <-time.After(50 * time.Millisecond): + } + + if err := a.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("emitter did not return promptly after Close") + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_grok_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_grok_test.go index b8b946a81c..8d2eeae76f 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_grok_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_grok_test.go @@ -519,7 +519,49 @@ func TestGrokUserMessageEchoIsSuppressedWithoutDroppingContext(t *testing.T) { } } -func TestGrokContextRetriesAfterFullUpdateChannel(t *testing.T) { +// TestGrokContextParksOnFullUpdateChannelThenDelivers pins +// AC-EXECUTORS-SURVIVAL-001.5/.6 for this COVERED site: a full updatesCh +// must park emitDialectContextWindow rather than dropping the sample, and +// deliver it once a consumer drains a slot -- replacing the old drop-and- +// retry-on-next-notification behavior. +func TestGrokContextParksOnFullUpdateChannelThenDelivers(t *testing.T) { + models := []modelInfo{{ + ModelId: "grok-current", + Meta: map[string]any{"totalContextTokens": float64(500_000)}, + }} + a := grokAdapterWithModels(t, models, []streams.ConfigOption{{ + Type: "select", ID: configOptionIDModel, Category: configOptionIDModel, CurrentValue: "grok-current", + }}) + t.Cleanup(func() { _ = a.Close() }) + a.updatesCh = make(chan AgentEvent, 1) + a.updatesCh <- AgentEvent{Type: streams.EventTypeMessageChunk} + meta := map[string]any{"totalTokens": float64(42_000)} + + done := make(chan *AgentEvent, 1) + go func() { done <- a.emitDialectContextWindow("sess-grok", meta) }() + + select { + case <-done: + t.Fatal("emitDialectContextWindow returned before the channel had room -- it must park, not drop") + case <-time.After(50 * time.Millisecond): + } + + <-a.updatesCh // drain the pre-filled slot, freeing room for the parked send + + select { + case event := <-done: + if event == nil || event.Type != streams.EventTypeContextWindow { + t.Fatalf("emitDialectContextWindow() = %#v after room freed up, want a context_window event", event) + } + case <-time.After(time.Second): + t.Fatal("emitDialectContextWindow did not return promptly after the channel had room") + } +} + +// TestGrokContextParkReleasesOnAdapterClose pins the pause-not-a-hang half: +// a parked emitDialectContextWindow call must release once the adapter +// closes, without ever delivering the event. +func TestGrokContextParkReleasesOnAdapterClose(t *testing.T) { models := []modelInfo{{ ModelId: "grok-current", Meta: map[string]any{"totalContextTokens": float64(500_000)}, @@ -531,16 +573,23 @@ func TestGrokContextRetriesAfterFullUpdateChannel(t *testing.T) { a.updatesCh <- AgentEvent{Type: streams.EventTypeMessageChunk} meta := map[string]any{"totalTokens": float64(42_000)} - if event := a.emitDialectContextWindow("sess-grok", meta); event != nil { - t.Fatalf("emitDialectContextWindow() = %#v with full channel, want nil", event) - } - if _, cached := a.contextSamples["sess-grok"]; cached { - t.Fatal("dropped context sample must not be cached") - } - <-a.updatesCh + done := make(chan *AgentEvent, 1) + go func() { done <- a.emitDialectContextWindow("sess-grok", meta) }() - if event := a.emitDialectContextWindow("sess-grok", meta); event == nil { - t.Fatal("same context sample must be retried after channel capacity returns") + select { + case <-done: + t.Fatal("emitDialectContextWindow returned before the channel had room or the adapter closed") + case <-time.After(50 * time.Millisecond): + } + + if err := a.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("emitDialectContextWindow did not return promptly after Close") } } diff --git a/apps/backend/internal/agentctl/server/api/agent.go b/apps/backend/internal/agentctl/server/api/agent.go index c93f3dfe45..ad9e8fcfc3 100644 --- a/apps/backend/internal/agentctl/server/api/agent.go +++ b/apps/backend/internal/agentctl/server/api/agent.go @@ -154,9 +154,33 @@ func (s *Server) handleAgentStreamWS(c *gin.Context) { s.logger.Info("agent stream WebSocket connected") streamID := uuid.NewString() + // This is the agentctl-local "instance is attached" signal + // (AC-EXECUTORS-SURVIVAL-001.5/.6): the permission-request notification + // site reads it to decide whether to auto-cancel on a five-second + // timeout (attached) or park (detached) when its channel is full. + s.procMgr.MarkAttached() + defer s.procMgr.MarkDetached() + ctx, cancel := context.WithCancel(c.Request.Context()) defer cancel() + // AC-EXECUTORS-CONTROL-OWNERSHIP-002.2: terminate this stream if the + // control server's credential rotates while it is open, so a prior + // holder cannot keep consuming an instance's events past the moment its + // credential is superseded. The channel comes from instanceAuth's + // context value, captured atomically with the request's own accept + // check -- not a fresh Invalidated() call here, which would be a second, + // independent lock acquisition racing a concurrent rotation. + if invalidated := credentialInvalidatedFromContext(c); invalidated != nil { + go func() { + select { + case <-invalidated: + cancel() + case <-ctx.Done(): + } + }() + } + // Use a mutex for writing to the WebSocket var writeMu sync.Mutex writeMessage := func(data []byte) error { @@ -658,6 +682,11 @@ func (s *Server) handleWSPrompt(ctx context.Context, msg *ws.Message) *ws.Messag return resp } + // The retained slot belongs to the previous terminal turn. Clear it before + // accepting this prompt so recovery cannot replay an old completion as the + // result of the new turn. + s.procMgr.ClearTurnOutcome(req.PromptGeneration) + // Cancel any pending permissions so the agent isn't blocked waiting for // the user to approve a previous tool call while processing the new prompt. s.procMgr.CancelPendingPermissions() diff --git a/apps/backend/internal/agentctl/server/api/auth.go b/apps/backend/internal/agentctl/server/api/auth.go index eb4982b749..7d59c20828 100644 --- a/apps/backend/internal/agentctl/server/api/auth.go +++ b/apps/backend/internal/agentctl/server/api/auth.go @@ -8,6 +8,34 @@ import ( "github.com/gin-gonic/gin" ) +// Shared auth-rejection messages, reused by bearerTokenAuth, instanceAuth, +// and controlCredentialAuth (credential_rotation.go) so the three auth +// checks -- static token, per-instance dynamic credential, and control-plane +// rotating credential -- report identical wording (goconst: 3+ occurrences). +const ( + errMissingAuthHeader = "missing or invalid Authorization header" + errInvalidAuthToken = "invalid auth token" +) + +// credentialInvalidatedContextKey is the gin context key instanceAuth uses to +// hand a stream handler the invalidation channel for the exact credential +// generation that authenticated the request. A stream handler must read it +// from here rather than calling credentialSource.Invalidated() itself: that +// would be a second, independent lock acquisition, and a rotation landing +// between the two could authenticate the request against the generation it +// just superseded while handing the handler a channel for the generation +// that replaced it -- a channel that never closes for the rotation that +// actually invalidated the accepted credential (AC-EXECUTORS-CONTROL- +// OWNERSHIP-002.2). +const credentialInvalidatedContextKey = "credentialInvalidated" + +// afterInstanceAuthAccepted is invoked, if non-nil, immediately after +// instanceAuth accepts a credentialSource-backed request and captures its +// invalidation channel, before the request is allowed to proceed. It exists +// solely so a test can deterministically land a concurrent rotation in this +// exact window; always nil in production. +var afterInstanceAuthAccepted func() + // bearerTokenAuth returns a gin middleware that validates a Bearer token // on every request except the exempted paths (e.g., /health). // If expectedToken is empty, authentication is disabled (no-op middleware). @@ -29,16 +57,12 @@ func bearerTokenAuth(expectedToken string, exemptPaths ...string) gin.HandlerFun token := extractBearerToken(c.Request) if token == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": "missing or invalid Authorization header", - }) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errKey: errMissingAuthHeader}) return } if !tokenEqual(token, expectedToken) { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": "invalid auth token", - }) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errKey: errInvalidAuthToken}) return } @@ -46,6 +70,82 @@ func bearerTokenAuth(expectedToken string, exemptPaths ...string) gin.HandlerFun } } +// instanceAuth returns a gin middleware validating a Bearer token on every +// request except the exempted paths, exactly like bearerTokenAuth, but reads +// its accepted token dynamically on each request rather than capturing one +// at registration time. +// +// When s.credentialSource is set (via SetCredentialSource), it authenticates +// against the control server's current highest-numbered rotation instead of +// staticToken -- the single-credential model of design 01 "Single driver" +// (AC-EXECUTORS-CONTROL-OWNERSHIP-002.6). s.credentialSource is read fresh +// per request rather than baked into a closure at Use()-time because +// SetCredentialSource is only ever called after NewServer returns, once the +// caller has a control server to wire it to. +// +// When credentialSource is nil, behavior is identical to +// bearerTokenAuth(staticToken, exemptPaths...), including "empty token +// disables auth" -- every existing test constructing a Server directly is +// unaffected. +func (s *Server) instanceAuth(staticToken string, exemptPaths ...string) gin.HandlerFunc { + exempt := make(map[string]bool, len(exemptPaths)) + for _, p := range exemptPaths { + exempt[p] = true + } + + return func(c *gin.Context) { + if exempt[c.Request.URL.Path] { + c.Next() + return + } + + if s.credentialSource == nil && staticToken == "" { + c.Next() + return + } + + token := extractBearerToken(c.Request) + if token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errKey: errMissingAuthHeader}) + return + } + + if s.credentialSource != nil { + accepted, invalidated := s.credentialSource.AcceptsFullWithInvalidation(token) + if !accepted { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errKey: errInvalidAuthToken}) + return + } + if afterInstanceAuthAccepted != nil { + afterInstanceAuthAccepted() + } + c.Set(credentialInvalidatedContextKey, invalidated) + c.Next() + return + } + + if !tokenEqual(token, staticToken) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errKey: errInvalidAuthToken}) + return + } + c.Next() + } +} + +// credentialInvalidatedFromContext reads the invalidation channel instanceAuth +// captured atomically with its accept check (see credentialInvalidatedContextKey). +// Returns nil when no credentialSource is wired -- the caller's watcher +// goroutine is then correctly skipped, matching legacy no-control-server +// behavior. +func credentialInvalidatedFromContext(c *gin.Context) <-chan struct{} { + v, ok := c.Get(credentialInvalidatedContextKey) + if !ok { + return nil + } + ch, _ := v.(<-chan struct{}) + return ch +} + // extractBearerToken extracts the token from the Authorization header. func extractBearerToken(r *http.Request) string { const prefix = "Bearer " diff --git a/apps/backend/internal/agentctl/server/api/control_instance_list_test.go b/apps/backend/internal/agentctl/server/api/control_instance_list_test.go new file mode 100644 index 0000000000..9ab8670f9f --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/control_instance_list_test.go @@ -0,0 +1,122 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/instance" + "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/agent" +) + +// TestListInstancesReturnsEnvelopeWithSessionAndTaskID drives GET +// /api/v1/instances through the real handler and the real agentctl client +// (agentctl.ControlClient), rather than a hand-written response fixture. +// agentctl.ControlClient.ListInstances has always decoded a +// {"instances": [...]} envelope, but handleListInstances wrote the bare +// []*InstanceInfo array straight from instance.Manager.ListInstances — a +// mismatch a fixture-based client test can never see, since it fabricates +// the "correct" response by hand instead of asking the server for one. It +// also pins that CreateInstance's SessionID/TaskID survive into the listed +// InstanceInfo, needed to correlate a recovered instance back to its owning +// session on a backend restart (discoveries G, J). +func TestListInstancesReturnsEnvelopeWithSessionAndTaskID(t *testing.T) { + log := logger.Default() + mgr := instance.NewManager(&config.Config{ + Ports: config.PortConfig{Base: 0, Max: 0}, + Defaults: config.InstanceDefaults{Protocol: agent.ProtocolACP}, + }, log) + t.Cleanup(func() { _ = mgr.Shutdown(t.Context()) }) + mgr.SetServerFactory(func(*config.InstanceConfig, *process.Manager, *logger.Logger) http.Handler { + return http.NotFoundHandler() + }) + + created, err := mgr.CreateInstance(t.Context(), &instance.CreateRequest{ + WorkspacePath: t.TempDir(), + SessionID: "session-123", + TaskID: "task-456", + }) + if err != nil { + t.Fatalf("CreateInstance: %v", err) + } + t.Cleanup(func() { _ = mgr.StopInstance(t.Context(), created.ID) }) + + cs := NewControlServer(&config.Config{}, mgr, log) + server := httptest.NewServer(cs.Router()) + defer server.Close() + host, port := parseHostPort(t, server.URL) + client := agentctl.NewControlClient(host, port, log) + + instances, err := client.ListInstances(t.Context()) + if err != nil { + t.Fatalf("ListInstances: %v (handler response did not decode as the {\"instances\":[...]} envelope the client expects)", err) + } + if len(instances) != 1 { + t.Fatalf("instances = %d, want 1", len(instances)) + } + got := instances[0] + if got.ID != created.ID { + t.Errorf("ID = %q, want %q", got.ID, created.ID) + } + if got.SessionID != "session-123" { + t.Errorf("SessionID = %q, want %q", got.SessionID, "session-123") + } + if got.TaskID != "task-456" { + t.Errorf("TaskID = %q, want %q", got.TaskID, "task-456") + } +} + +// TestListInstancesReturnsLiveWorkspaceSourceRoots drives GET +// /api/v1/instances through the real handler and the real agentctl client to +// pin AC-EXECUTORS-SURVIVAL-002.14's "workspace source roots" reconstruction +// row: the backend must read this back from the adopted instance rather than +// push its own, so the wire contract has to expose whatever the instance is +// currently enforcing, not just what it was created with. +func TestListInstancesReturnsLiveWorkspaceSourceRoots(t *testing.T) { + log := logger.Default() + mgr := instance.NewManager(&config.Config{ + Ports: config.PortConfig{Base: 0, Max: 0}, + Defaults: config.InstanceDefaults{Protocol: agent.ProtocolACP}, + }, log) + t.Cleanup(func() { _ = mgr.Shutdown(t.Context()) }) + mgr.SetServerFactory(func(*config.InstanceConfig, *process.Manager, *logger.Logger) http.Handler { + return http.NotFoundHandler() + }) + + workspacePath, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + created, err := mgr.CreateInstance(t.Context(), &instance.CreateRequest{ + WorkspacePath: workspacePath, + SessionID: "session-123", + WorkspaceSourceRoots: []string{workspacePath}, + }) + if err != nil { + t.Fatalf("CreateInstance: %v", err) + } + t.Cleanup(func() { _ = mgr.StopInstance(t.Context(), created.ID) }) + + cs := NewControlServer(&config.Config{}, mgr, log) + server := httptest.NewServer(cs.Router()) + defer server.Close() + host, port := parseHostPort(t, server.URL) + client := agentctl.NewControlClient(host, port, log) + + instances, err := client.ListInstances(t.Context()) + if err != nil { + t.Fatalf("ListInstances: %v", err) + } + if len(instances) != 1 { + t.Fatalf("instances = %d, want 1", len(instances)) + } + got := instances[0].WorkspaceSourceRoots + if len(got) != 1 || got[0] != workspacePath { + t.Errorf("WorkspaceSourceRoots = %v, want [%q]", got, workspacePath) + } +} diff --git a/apps/backend/internal/agentctl/server/api/control_server.go b/apps/backend/internal/agentctl/server/api/control_server.go index 7e75c72ed5..0c76d51bd8 100644 --- a/apps/backend/internal/agentctl/server/api/control_server.go +++ b/apps/backend/internal/agentctl/server/api/control_server.go @@ -4,6 +4,7 @@ package api import ( "context" "net/http" + "sync" "time" "github.com/gin-gonic/gin" @@ -12,6 +13,7 @@ import ( commonconfig "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/httpmw" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/common/ownershipperiod" "github.com/kandev/kandev/internal/common/subproc" "go.uber.org/zap" ) @@ -19,26 +21,68 @@ import ( // ControlServer provides instance management endpoints on the control port. // It exposes the same API regardless of deployment context (Docker or host). // Each instance runs its own HTTP server with agent-specific endpoints. +// adoptionOnlyPaths lists control-server routes where a superseded +// credential remains valid on the adoption-only terms of +// AC-EXECUTORS-CONTROL-OWNERSHIP-002.7/-002.9, until confirmed. Every other +// authenticated route accepts only the current highest-numbered rotation's +// credential. +var adoptionOnlyPaths = map[string]bool{ + "/api/v1/ownership/rotate": true, + "/api/v1/ownership/shutdown": true, +} + type ControlServer struct { - cfg *config.Config - instMgr *instance.Manager - logger *logger.Logger - router *gin.Engine + cfg *config.Config + instMgr *instance.Manager + logger *logger.Logger + router *gin.Engine + ownership *ownershipState + credentials *credentialState + shutdownRequested chan struct{} + shutdownOnce sync.Once + unownedPeriod time.Duration + reaperStop chan struct{} + reaperStopOnce sync.Once + reaperWG sync.WaitGroup + + // decideUnownedShutdown is the reaper's shutdown decision, defaulted to + // ownership.TryBeginShutdownIfUnownedFor. Tests override it to pin the + // exact call the reaper makes rather than only observing its side + // effects, so reverting to a stale two-call sequence (check then latch, + // instead of the atomic check-and-latch) fails a test even though it + // would still pass every other reaper assertion. + decideUnownedShutdown func(time.Duration) bool } // NewControlServer creates a new ControlServer for instance management. func NewControlServer(cfg *config.Config, instMgr *instance.Manager, log *logger.Logger) *ControlServer { gin.SetMode(gin.ReleaseMode) - cs := &ControlServer{ - cfg: cfg, - instMgr: instMgr, - logger: log.WithFields(zap.String("component", "control-server")), - router: gin.New(), + csLogger := log.WithFields(zap.String("component", "control-server")) + unownedPeriod, adjustments := ownershipperiod.Resolve(cfg.UnownedPeriod, cfg.IdleTimeout) + for _, reason := range adjustments { + csLogger.Warn(reason, + zap.Duration("configured_unowned_period", cfg.UnownedPeriod), + zap.Duration("idle_timeout", cfg.IdleTimeout), + zap.Duration("effective_unowned_period", unownedPeriod)) } + cs := &ControlServer{ + cfg: cfg, + instMgr: instMgr, + logger: csLogger, + router: gin.New(), + ownership: newOwnershipState(), + credentials: newCredentialState(cfg.AuthToken), + shutdownRequested: make(chan struct{}), + unownedPeriod: unownedPeriod, + reaperStop: make(chan struct{}), + } + + cs.decideUnownedShutdown = cs.ownership.TryBeginShutdownIfUnownedFor + cs.router.Use(httpmw.RequestLogger(cs.logger, "agentctl-control")) - cs.router.Use(bearerTokenAuth(cfg.AuthToken, "/health", "/auth/handshake")) + cs.router.Use(controlCredentialAuth(cs.credentials, adoptionOnlyPaths, "/health", "/auth/handshake", "/identity", "/ownership/prove")) cs.setupRoutes() return cs @@ -49,6 +93,30 @@ func (m *ControlServer) Router() http.Handler { return m.router } +// CredentialSource exposes this control server's single rotating credential +// so a per-instance Server can authenticate its own requests and streams +// against it instead of a static per-instance token (design 01 "Single +// driver", AC-EXECUTORS-CONTROL-OWNERSHIP-002.6). Wire it via +// Server.SetCredentialSource before an instance starts accepting requests. +func (m *ControlServer) CredentialSource() InstanceCredentialSource { + return m.credentials +} + +// ShutdownRequested returns a channel that closes exactly once the +// ownership-shutdown operation (AC-EXECUTORS-CONTROL-OWNERSHIP-002.9) has +// been invoked. The run loop (cmd/agentctl/main.go) selects on this +// alongside its OS-signal and parent-death triggers and runs the same +// stop-every-instance-and-exit sequence. +func (m *ControlServer) ShutdownRequested() <-chan struct{} { + return m.shutdownRequested +} + +// requestShutdown signals ShutdownRequested exactly once. Safe to call more +// than once (a repeated ownership-shutdown call is a no-op beyond the first). +func (m *ControlServer) requestShutdown() { + m.shutdownOnce.Do(func() { close(m.shutdownRequested) }) +} + func (m *ControlServer) setupRoutes() { // Health check m.router.GET("/health", m.handleHealth) @@ -56,13 +124,27 @@ func (m *ControlServer) setupRoutes() { // Bootstrap handshake — nonce-authenticated, returns the self-generated auth token m.router.POST("/auth/handshake", m.handleHandshake) + // Identity/capability — unauthenticated, decides adoption compatibility + m.router.GET("/identity", m.handleIdentity) + + // Ownership proof — unauthenticated, establishes to an adopting backend + // that this process holds the credential before that backend sends it + m.router.POST("/ownership/prove", m.handleOwnershipProve) + // Instance management API - same endpoints regardless of mode api := m.router.Group("/api/v1") api.POST("/instances", m.handleCreateInstance) api.GET("/instances", m.handleListInstances) api.GET("/instances/:id", m.handleGetInstance) api.DELETE("/instances/:id", m.handleDeleteInstance) + api.GET("/instances/:id/turn-outcome", m.handleGetTurnOutcome) + api.POST("/instances/:id/turn-outcome/ack", m.handleAckTurnOutcome) api.GET("/debug/subprocess-admission", m.handleSubprocessAdmission) + api.GET("/ownership/details", m.handleOwnershipDetails) + api.POST("/ownership/claim", m.handleOwnershipClaim) + api.POST("/ownership/rotate", m.handleCredentialRotate) + api.POST("/ownership/confirm", m.handleCredentialConfirm) + api.POST("/ownership/shutdown", m.handleOwnershipShutdown) } func (m *ControlServer) handleSubprocessAdmission(c *gin.Context) { @@ -104,6 +186,7 @@ func (m *ControlServer) handleHandshake(c *gin.Context) { return } + m.ownership.Renew() m.logger.Info("bootstrap handshake completed, auth token issued", zap.String("nonce_fingerprint", commonconfig.NonceFingerprint(req.Nonce))) c.JSON(http.StatusOK, gin.H{"token": token}) @@ -141,7 +224,7 @@ func (m *ControlServer) handleCreateInstance(c *gin.Context) { func (m *ControlServer) handleListInstances(c *gin.Context) { instances := m.instMgr.ListInstances() - c.JSON(http.StatusOK, instances) + c.JSON(http.StatusOK, gin.H{"instances": instances}) } func (m *ControlServer) handleGetInstance(c *gin.Context) { diff --git a/apps/backend/internal/agentctl/server/api/credential_fencing.go b/apps/backend/internal/agentctl/server/api/credential_fencing.go new file mode 100644 index 0000000000..bd7f173c88 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/credential_fencing.go @@ -0,0 +1,65 @@ +package api + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +// Long-lived connections outlive the request that authenticated them, so the +// credential check they passed at connect time is the only one they ever get. +// The credential issued by the highest-numbered rotation is the only one that +// authenticates a stream, which means a connection whose credential has been +// superseded must be torn down rather than left reading and writing the +// workspace after every ordinary request from the same holder is refused. +// +// Both helpers take the invalidation channel from the gin context, where the +// authenticating accept check placed it. Calling Invalidated() afresh here +// would reopen a window in which a rotation between the accept check and the +// lookup hands back the channel for the generation that replaced the one just +// authenticated -- a channel that never closes for the rotation that actually +// superseded this connection's credential. + +// closeOnCredentialInvalidation closes conn once the credential this request +// authenticated under is superseded. The returned function releases the +// watcher and must be called when the handler returns. +func closeOnCredentialInvalidation(c *gin.Context, conn *websocket.Conn) func() { + invalidated := credentialInvalidatedFromContext(c) + if invalidated == nil { + return func() {} + } + done := make(chan struct{}) + go func() { + select { + case <-invalidated: + _ = conn.Close() + case <-done: + } + }() + return func() { close(done) } +} + +// credentialScopedRequest returns c.Request bound to a context that is +// cancelled once the credential it authenticated under is superseded. +// httputil.ReverseProxy closes the upstream connection when the inbound +// request's context is cancelled, including for a connection it has upgraded, +// so a proxied socket terminates with the credential that opened it. The +// returned function releases the watcher and must be called when the handler +// returns. +func credentialScopedRequest(c *gin.Context) (*http.Request, func()) { + invalidated := credentialInvalidatedFromContext(c) + if invalidated == nil { + return c.Request, func() {} + } + ctx, cancel := context.WithCancel(c.Request.Context()) + go func() { + select { + case <-invalidated: + cancel() + case <-ctx.Done(): + } + }() + return c.Request.WithContext(ctx), cancel +} diff --git a/apps/backend/internal/agentctl/server/api/credential_rotation.go b/apps/backend/internal/agentctl/server/api/credential_rotation.go new file mode 100644 index 0000000000..953c67bbd8 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/credential_rotation.go @@ -0,0 +1,307 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "net/http" + "sync" + + "github.com/gin-gonic/gin" +) + +// errRotationCredentialRejected is returned when Rotate is presented a +// credential outside the acceptable set of +// AC-EXECUTORS-CONTROL-OWNERSHIP-002.8. +var errRotationCredentialRejected = errors.New("credential not accepted for rotation") + +// errRotationIDNotIssued is returned when Confirm names a rotation +// identifier the control server never allocated. +var errRotationIDNotIssued = errors.New("rotation identifier not issued") + +// InstanceCredentialSource is the narrow surface a per-instance Server +// authenticates its own requests and streams against, so that a single +// rotating credential -- not a static per-instance token -- is "the only +// one that authenticates an instance operation or a stream" +// (AC-EXECUTORS-CONTROL-OWNERSHIP-002.6, design 01 "Single driver"). +// Satisfied by *credentialState; ControlServer.CredentialSource exposes it. +type InstanceCredentialSource interface { + // AcceptsFull reports whether token is the current highest-numbered + // rotation's credential. + AcceptsFull(token string) bool + // Invalidated returns a channel that closes the moment the next NEW + // rotation (not an idempotent replay) succeeds. Call it fresh per + // connection: the returned channel is single-shot, and a connection + // established after a rotation must observe a later one, not the + // already-closed channel from before it connected. + Invalidated() <-chan struct{} + // AcceptsFullWithInvalidation atomically reports whether token is the + // current highest-numbered rotation's credential and, when accepted, + // the invalidation channel for that exact generation. A caller that + // holds a connection open past the check (a stream) must capture the + // channel from this same call rather than calling AcceptsFull followed + // by a separate Invalidated(): a rotation landing between two + // independent lock acquisitions could authenticate against the + // generation it just superseded while handing back the channel for the + // generation that replaced it, which never closes for the rotation that + // actually invalidated the accepted credential + // (AC-EXECUTORS-CONTROL-OWNERSHIP-002.2). + AcceptsFullWithInvalidation(token string) (bool, <-chan struct{}) +} + +// credentialState is the two-phase credential rotation machinery of design +// 01's "Single driver" section. It starts holding a single fully- +// authenticating credential (the bootstrap token) and no superseded +// credential -- the state a freshly spawned server is in before any +// adoption has ever rotated it. +// +// A rotation replaces the fully-authenticating credential and demotes the +// previous one to "adoption-only" (it authenticates only a further rotation +// attempt or the ownership-shutdown operation, AC-002.7/AC-002.9) until the +// adopting backend confirms it has durably stored the replacement, at which +// point the superseded credential is dropped entirely. +type credentialState struct { + mu sync.Mutex + rotationID int64 + latest string + superseded string + unconfirmed bool + // invalidated is closed and replaced with a fresh channel on every NEW + // rotation (AC-EXECUTORS-CONTROL-OWNERSHIP-002.2): every per-instance + // stream handler watching the channel it captured at connect time + // observes the close and terminates, so a prior holder cannot keep + // consuming an instance's events once its credential is superseded. An + // idempotent replay (AC-002.10) allocates no new rotation and therefore + // does not invalidate anything -- nothing established since the last + // real rotation could have used a credential the replay superseded. + invalidated chan struct{} +} + +var _ InstanceCredentialSource = (*credentialState)(nil) + +// newCredentialState seeds the machinery with the control server's initial +// (bootstrap-minted) credential. No rotation has happened yet, so there is +// no superseded credential and nothing to confirm. +func newCredentialState(initial string) *credentialState { + return &credentialState{latest: initial, invalidated: make(chan struct{})} +} + +// Invalidated implements InstanceCredentialSource. +func (c *credentialState) Invalidated() <-chan struct{} { + c.mu.Lock() + defer c.mu.Unlock() + return c.invalidated +} + +// Latest returns the credential that authenticates every operation. +func (c *credentialState) Latest() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.latest +} + +// AcceptsFull reports whether token authenticates a normal instance +// operation or stream (AC-EXECUTORS-CONTROL-OWNERSHIP-002.6): only the +// credential issued by the current highest-numbered rotation qualifies. +func (c *credentialState) AcceptsFull(token string) bool { + c.mu.Lock() + defer c.mu.Unlock() + return tokenEqual(token, c.latest) +} + +// AcceptsFullWithInvalidation implements InstanceCredentialSource. +func (c *credentialState) AcceptsFullWithInvalidation(token string) (bool, <-chan struct{}) { + c.mu.Lock() + defer c.mu.Unlock() + if !tokenEqual(token, c.latest) { + return false, nil + } + return true, c.invalidated +} + +// AcceptsAdoptionOnly reports whether token authenticates a further +// rotation attempt or the ownership-shutdown operation +// (AC-EXECUTORS-CONTROL-OWNERSHIP-002.7, -002.9): the latest credential +// always qualifies, and the superseded one qualifies only while its +// rotation remains unconfirmed. +func (c *credentialState) AcceptsAdoptionOnly(token string) bool { + c.mu.Lock() + defer c.mu.Unlock() + if tokenEqual(token, c.latest) { + return true + } + return c.unconfirmed && c.superseded != "" && tokenEqual(token, c.superseded) +} + +// Rotate replaces the latest credential. presented must be the current +// latest credential (an ordinary rotation) or, while the previous rotation +// remains unconfirmed, the credential it superseded -- which is an +// idempotent retry (AC-EXECUTORS-CONTROL-OWNERSHIP-002.10) that allocates +// no new rotation and returns the same identifier and replacement again. +func (c *credentialState) Rotate(presented string) (rotationID int64, replacement string, err error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.unconfirmed && c.superseded != "" && tokenEqual(presented, c.superseded) { + return c.rotationID, c.latest, nil + } + if !tokenEqual(presented, c.latest) { + return 0, "", errRotationCredentialRejected + } + + replacement = generateRotationCredential() + c.superseded = c.latest + c.latest = replacement + c.rotationID++ + c.unconfirmed = true + close(c.invalidated) + c.invalidated = make(chan struct{}) + return c.rotationID, c.latest, nil +} + +// Confirm names the rotation the caller has durably stored (both the +// replacement credential and the control-server record's reference to it). +// Naming the current highest-numbered rotation -- for the first time or +// repeated -- drops the superseded credential and is accepted. Naming any +// earlier identifier is accepted and has no effect, so a delayed or +// duplicated confirmation can never revoke a credential issued after it. +// Naming an identifier never issued is rejected. +func (c *credentialState) Confirm(rotationID int64) error { + c.mu.Lock() + defer c.mu.Unlock() + + if rotationID <= 0 || rotationID > c.rotationID { + return errRotationIDNotIssued + } + if rotationID < c.rotationID { + return nil + } + c.unconfirmed = false + c.superseded = "" + return nil +} + +func generateRotationCredential() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand failed: " + err.Error()) + } + return hex.EncodeToString(b) +} + +// controlCredentialAuth authenticates control-server requests against the +// rotating credential set in creds, distinguishing adoption-only paths +// (where the superseded credential remains valid until confirmed, per +// AC-002.7/-002.9) from every other path (where only the current +// highest-numbered rotation's credential is valid). Mirrors +// bearerTokenAuth's "no credential configured disables auth" behavior when +// creds started out empty. +func controlCredentialAuth(creds *credentialState, adoptionOnlyPaths map[string]bool, exemptPaths ...string) gin.HandlerFunc { + if creds.Latest() == "" { + return func(c *gin.Context) { c.Next() } + } + + exempt := make(map[string]bool, len(exemptPaths)) + for _, p := range exemptPaths { + exempt[p] = true + } + + return func(c *gin.Context) { + if exempt[c.Request.URL.Path] { + c.Next() + return + } + + token := extractBearerToken(c.Request) + if token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errKey: errMissingAuthHeader}) + return + } + + var ok bool + if adoptionOnlyPaths[c.Request.URL.Path] { + ok = creds.AcceptsAdoptionOnly(token) + } else { + ok = creds.AcceptsFull(token) + } + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errKey: errInvalidAuthToken}) + return + } + + c.Next() + } +} + +type confirmRotationRequest struct { + RotationID int64 `json:"rotation_id"` +} + +// afterCredentialRotateAccepted is invoked, if non-nil, immediately after +// credentials.Rotate succeeds and before ownership.Renew is called in +// handleCredentialRotate. It exists solely so a test can deterministically +// reproduce the window a concurrent unowned-shutdown reaper could latch +// shutdown in between those two calls; always nil in production. +var afterCredentialRotateAccepted func() + +// handleCredentialRotate is the rotate half of the two-phase rotation. +// Refused once the one-way unowned-shutdown door has fired (AC-003.9): a +// rotation is the first step of an adoption attempt, and a decided +// shutdown must never be reopened. +func (m *ControlServer) handleCredentialRotate(c *gin.Context) { + if m.ownership.IsShuttingDown() { + c.JSON(http.StatusConflict, gin.H{errKey: shuttingDownMessage}) + return + } + + presented := extractBearerToken(c.Request) + rotationID, replacement, err := m.credentials.Rotate(presented) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{errKey: err.Error()}) + return + } + + if afterCredentialRotateAccepted != nil { + afterCredentialRotateAccepted() + } + + // A successful rotation -- fresh or an idempotent replay -- renews + // ownership: it is the first authenticated operation an adopting + // backend issues (design 01, "Unowned shutdown"). A shutdown that + // latches in the gap between the IsShuttingDown check above and this + // call must still be honored: Renew reports false once that happens, + // and this response must not claim success over a server that is + // already tearing down. + if !m.ownership.Renew() { + c.JSON(http.StatusConflict, gin.H{errKey: shuttingDownMessage}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "rotation_id": rotationID, + "credential": replacement, + }) +} + +// handleCredentialConfirm is the confirm half of the two-phase rotation. +// It does not itself renew ownership: only the handshake, the claim +// operation, and rotation do (AC-003.8). +func (m *ControlServer) handleCredentialConfirm(c *gin.Context) { + if m.ownership.IsShuttingDown() { + c.JSON(http.StatusConflict, gin.H{errKey: shuttingDownMessage}) + return + } + + var req confirmRotationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{errKey: "missing or invalid rotation_id"}) + return + } + + if err := m.credentials.Confirm(req.RotationID); err != nil { + c.JSON(http.StatusNotFound, gin.H{errKey: err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{lspStatusKey: "confirmed"}) +} diff --git a/apps/backend/internal/agentctl/server/api/credential_rotation_extra_streams_test.go b/apps/backend/internal/agentctl/server/api/credential_rotation_extra_streams_test.go new file mode 100644 index 0000000000..52f07d3432 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/credential_rotation_extra_streams_test.go @@ -0,0 +1,234 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/kandev/kandev/internal/agentctl/server/process" +) + +// The four handlers covered here are the long-lived upgrades that sit behind +// the same instanceAuth group as the agent and workspace streams but forward +// through their own loops. AC-EXECUTORS-CONTROL-OWNERSHIP-002.6 makes the +// credential issued by the highest-numbered rotation the only one that +// authenticates a stream, so each must be torn down when its credential is +// superseded -- otherwise a superseded backend keeps a live PTY, language +// server, or proxied socket into the workspace after being fenced out of +// every ordinary request. + +// upstreamEchoWebSocket starts a server that upgrades and echoes every frame, +// standing in for whatever listens on a proxied port. +func upstreamEchoWebSocket(t *testing.T) *httptest.Server { + t.Helper() + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.Close() }() + for { + messageType, data, readErr := conn.ReadMessage() + if readErr != nil { + return + } + if writeErr := conn.WriteMessage(messageType, data); writeErr != nil { + return + } + } + })) + t.Cleanup(srv.Close) + return srv +} + +// startShellTerminalWithAuth starts a PTY-backed terminal through the routed +// server carrying a bearer credential, so the terminal exists as a specific +// holder before its stream is dialled. +func startShellTerminalWithAuth(t *testing.T, server *httptest.Server, token, terminalID string) { + t.Helper() + body, err := json.Marshal(shellTerminalStartRequest{TerminalID: terminalID, Cols: 80, Rows: 24}) + if err != nil { + t.Fatalf("marshal start request: %v", err) + } + req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/shell/terminal/start", bytes.NewReader(body)) + if err != nil { + t.Fatalf("build start request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("start terminal: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(resp.Body) + t.Fatalf("start terminal: status = %d (body %s)", resp.StatusCode, payload) + } +} + +// dialWithAuth dials an arbitrary path on the agentctl test server carrying a +// bearer credential, so the connection is established as a specific holder. +func dialWithAuth(t *testing.T, server *httptest.Server, path, token string) *websocket.Conn { + t.Helper() + header := http.Header{} + header.Set("Authorization", "Bearer "+token) + conn, _, err := websocket.DefaultDialer.Dial( + "ws"+strings.TrimPrefix(server.URL, "http")+path, header) + if err != nil { + t.Fatalf("dial %s: %v", path, err) + } + return conn +} + +// assertEchoRoundTrip proves a proxied socket is genuinely carrying traffic +// before a rotation, so a post-rotation closure cannot be mistaken for a +// connection that never worked in the first place. +func assertEchoRoundTrip(t *testing.T, conn *websocket.Conn) { + t.Helper() + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + if err := conn.WriteMessage(websocket.TextMessage, []byte("ping")); err != nil { + t.Fatalf("pre-rotation write: %v", err) + } + _, data, err := conn.ReadMessage() + if err != nil { + t.Fatalf("pre-rotation read: %v", err) + } + if string(data) != "ping" { + t.Fatalf("pre-rotation echo = %q, want %q", data, "ping") + } +} + +// TestShellTerminalStreamTerminatesWhenCredentialRotates is the highest-value +// case: the terminal stream's read loop writes raw bytes into the PTY, so a +// superseded holder that keeps it open retains arbitrary command execution in +// the workspace long after every other route rejects its credential. +func TestShellTerminalStreamTerminatesWhenCredentialRotates(t *testing.T) { + source := newCredentialState("initial-token") + srv := newTestServer(t) + srv.SetCredentialSource(source) + + httpServer := httptest.NewServer(srv.Router()) + defer httpServer.Close() + + t.Cleanup(func() { + if err := srv.procMgr.ShellManager().StopAll(); err != nil { + t.Errorf("stop all terminals: %v", err) + } + }) + const terminalID = "term-fencing" + startShellTerminalWithAuth(t, httpServer, "initial-token", terminalID) + + conn := dialWithAuth(t, httpServer, "/api/v1/shell/terminal/"+terminalID+"/stream", "initial-token") + defer func() { _ = conn.Close() }() + + // Drive the PTY once so the stream is proven live under the current + // credential before rotating. + const marker = "kandev-fencing-marker" + if err := conn.WriteMessage(websocket.BinaryMessage, []byte("echo "+marker+"\r")); err != nil { + t.Fatalf("pre-rotation terminal input: %v", err) + } + readShellTerminalUntil(t, conn, marker) + + if _, _, err := source.Rotate("initial-token"); err != nil { + t.Fatalf("Rotate: %v", err) + } + + assertConnectionClosedByServer(t, conn, 2*time.Second) +} + +// TestShellTerminalStreamTerminatesWhenCredentialRotatesBetweenAcceptAndCapture +// pins the same accept/capture window the agent and workspace streams already +// cover: the invalidation channel must come from instanceAuth's own accept +// check, never a fresh Invalidated() call that could hand back the generation +// which replaced the one just authenticated. +func TestShellTerminalStreamTerminatesWhenCredentialRotatesBetweenAcceptAndCapture(t *testing.T) { + source := newCredentialState("initial-token") + srv := newTestServer(t) + srv.SetCredentialSource(source) + + httpServer := httptest.NewServer(srv.Router()) + defer httpServer.Close() + + t.Cleanup(func() { + if err := srv.procMgr.ShellManager().StopAll(); err != nil { + t.Errorf("stop all terminals: %v", err) + } + }) + const terminalID = "term-fencing-gap" + startShellTerminalWithAuth(t, httpServer, "initial-token", terminalID) + + afterInstanceAuthAccepted = func() { + afterInstanceAuthAccepted = nil + if _, _, err := source.Rotate("initial-token"); err != nil { + t.Errorf("Rotate in accept/capture gap: %v", err) + } + } + t.Cleanup(func() { afterInstanceAuthAccepted = nil }) + + conn := dialWithAuth(t, httpServer, "/api/v1/shell/terminal/"+terminalID+"/stream", "initial-token") + defer func() { _ = conn.Close() }() + + assertConnectionClosedByServer(t, conn, 2*time.Second) +} + +// TestPortProxyTerminatesWhenCredentialRotates covers the proxied socket: the +// port proxy forwards an upgraded connection to an arbitrary local port, so a +// superseded holder keeping one open still reaches whatever listens there. +func TestPortProxyTerminatesWhenCredentialRotates(t *testing.T) { + upstream := upstreamEchoWebSocket(t) + port := portOf(t, upstream.URL) + + source := newCredentialState("initial-token") + srv := newTestServer(t) + srv.SetCredentialSource(source) + + httpServer := httptest.NewServer(srv.Router()) + defer httpServer.Close() + + conn := dialWithAuth(t, httpServer, "/api/v1/port-proxy/"+strconv.Itoa(port)+"/ws", "initial-token") + defer func() { _ = conn.Close() }() + + assertEchoRoundTrip(t, conn) + + if _, _, err := source.Rotate("initial-token"); err != nil { + t.Fatalf("Rotate: %v", err) + } + + assertConnectionClosedByServer(t, conn, 2*time.Second) +} + +// TestVscodeProxyTerminatesWhenCredentialRotates mirrors the port proxy for +// the code-server proxy, which forwards upgraded connections on the same +// terms. +func TestVscodeProxyTerminatesWhenCredentialRotates(t *testing.T) { + upstream := upstreamEchoWebSocket(t) + port := portOf(t, upstream.URL) + + source := newCredentialState("initial-token") + srv := newTestServer(t) + srv.SetCredentialSource(source) + srv.procMgr.SetVscodeForTest(process.VscodeStatusRunning, port) + + httpServer := httptest.NewServer(srv.Router()) + defer httpServer.Close() + + conn := dialWithAuth(t, httpServer, "/api/v1/vscode/proxy/ws", "initial-token") + defer func() { _ = conn.Close() }() + + assertEchoRoundTrip(t, conn) + + if _, _, err := source.Rotate("initial-token"); err != nil { + t.Fatalf("Rotate: %v", err) + } + + assertConnectionClosedByServer(t, conn, 2*time.Second) +} diff --git a/apps/backend/internal/agentctl/server/api/credential_rotation_lsp_stream_unix_test.go b/apps/backend/internal/agentctl/server/api/credential_rotation_lsp_stream_unix_test.go new file mode 100644 index 0000000000..2e6d6a90d9 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/credential_rotation_lsp_stream_unix_test.go @@ -0,0 +1,64 @@ +//go:build !windows + +package api + +import ( + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +// TestLSPStreamTerminatesWhenCredentialRotates completes the fencing coverage +// for the long-lived upgrades: the language-server bridge pipes client frames +// straight into a subprocess with the workspace as its root, so a superseded +// holder keeping one open still reads and edits the workspace through it. +func TestLSPStreamTerminatesWhenCredentialRotates(t *testing.T) { + binDir := t.TempDir() + serverPath := filepath.Join(binDir, "kotlin-lsp") + if err := os.WriteFile(serverPath, []byte("#!/bin/sh\nexec /bin/cat\n"), 0o755); err != nil { + t.Fatalf("write fake language server: %v", err) + } + t.Setenv("HOME", t.TempDir()) + // Prepend rather than replace: the package's later tests resolve git + // through a lookup cached the first time any test triggers it, so a test + // that empties PATH decides that answer for every test after it. + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + source := newCredentialState("initial-token") + srv := newTestServer(t) + srv.SetCredentialSource(source) + + httpServer := httptest.NewServer(srv.Router()) + defer httpServer.Close() + + conn := dialWithAuth(t, httpServer, "/api/v1/lsp/stream?language=kotlin", "initial-token") + defer func() { _ = conn.Close() }() + + // Read the ready frame and echo one request through the bridge, so the + // post-rotation closure cannot be mistaken for a bridge that never came up. + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + if _, _, err := conn.ReadMessage(); err != nil { + t.Fatalf("read ready frame: %v", err) + } + payload := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`) + if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil { + t.Fatalf("pre-rotation write: %v", err) + } + _, echoed, err := conn.ReadMessage() + if err != nil { + t.Fatalf("pre-rotation read: %v", err) + } + if string(echoed) != string(payload) { + t.Fatalf("echoed payload = %s, want %s", echoed, payload) + } + + if _, _, err := source.Rotate("initial-token"); err != nil { + t.Fatalf("Rotate: %v", err) + } + + assertConnectionClosedByServer(t, conn, 2*time.Second) +} diff --git a/apps/backend/internal/agentctl/server/api/credential_rotation_stream_test.go b/apps/backend/internal/agentctl/server/api/credential_rotation_stream_test.go new file mode 100644 index 0000000000..41c16bf253 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/credential_rotation_stream_test.go @@ -0,0 +1,290 @@ +package api + +import ( + "errors" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/kandev/kandev/internal/agentctl/types" +) + +// fakeCredentialSource is a minimal InstanceCredentialSource test double: it +// accepts exactly one token and exposes a channel the caller can close +// directly, without going through credentialState's rotation bookkeeping. +type fakeCredentialSource struct { + accepted string + invalidated chan struct{} +} + +func newFakeCredentialSource(accepted string) *fakeCredentialSource { + return &fakeCredentialSource{accepted: accepted, invalidated: make(chan struct{})} +} + +func (f *fakeCredentialSource) AcceptsFull(token string) bool { return token == f.accepted } +func (f *fakeCredentialSource) Invalidated() <-chan struct{} { return f.invalidated } + +func (f *fakeCredentialSource) AcceptsFullWithInvalidation(token string) (bool, <-chan struct{}) { + if token != f.accepted { + return false, nil + } + return true, f.invalidated +} + +// TestInstanceAuthAcceptsCurrentCredentialRejectsSuperseded pins +// AC-EXECUTORS-CONTROL-OWNERSHIP-002.6: when a credentialSource is wired, +// instanceAuth authenticates every per-instance request dynamically against +// it instead of the static token captured at construction, so a rotation +// takes effect on the very next request with no server restart. +func TestInstanceAuthAcceptsCurrentCredentialRejectsSuperseded(t *testing.T) { + source := newFakeCredentialSource("current-token") + s := &Server{credentialSource: source} + r := gin.New() + r.Use(s.instanceAuth("static-token-ignored-when-source-set")) + r.GET("/api/v1/data", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": "secret"}) }) + + for _, tc := range []struct { + name string + token string + want int + }{ + {name: "current credential accepted", token: "current-token", want: http.StatusOK}, + {name: "superseded credential rejected", token: "static-token-ignored-when-source-set", want: http.StatusUnauthorized}, + {name: "unknown token rejected", token: "some-other-token", want: http.StatusUnauthorized}, + {name: "missing token rejected", token: "", want: http.StatusUnauthorized}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/data", nil) + if tc.token != "" { + req.Header.Set("Authorization", "Bearer "+tc.token) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != tc.want { + t.Fatalf("status = %d, want %d", w.Code, tc.want) + } + }) + } + + // Rotation takes effect immediately on the next request: no caching of + // AcceptsFull's answer anywhere in the middleware. + source.accepted = "rotated-token" + req := httptest.NewRequest(http.MethodGet, "/api/v1/data", nil) + req.Header.Set("Authorization", "Bearer current-token") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("pre-rotation credential after rotation: status = %d, want %d", w.Code, http.StatusUnauthorized) + } + req = httptest.NewRequest(http.MethodGet, "/api/v1/data", nil) + req.Header.Set("Authorization", "Bearer rotated-token") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("post-rotation credential: status = %d, want %d", w.Code, http.StatusOK) + } +} + +// dialTestWSWithAuth connects a WebSocket client to the test server's +// /api/v1/agent/stream endpoint carrying an Authorization header, so a test +// can dial as a specific credential holder rather than relying on +// instanceAuth being disabled (dialTestWS's no-token path). +func dialTestWSWithAuth(t *testing.T, server *httptest.Server, token string) *websocket.Conn { + t.Helper() + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/agent/stream" + header := http.Header{} + header.Set("Authorization", "Bearer "+token) + conn, _, err := websocket.DefaultDialer.Dial(wsURL, header) + if err != nil { + t.Fatalf("failed to dial WebSocket: %v", err) + } + return conn +} + +// assertConnectionClosedByServer reads from conn and requires the read to +// fail with something other than a deadline timeout. A bare "err == nil +// means still open" check cannot tell a deliberate server-side close apart +// from an untouched connection that simply outlived `within`: both produce +// a non-nil error from ReadMessage, but only the timeout case means the +// fencing mechanism never fired. Confirmed empirically: an identical dial +// with no rotation at all still errors with "i/o timeout" once `within` +// elapses, which a bare nil check would have accepted as proof of closure. +func assertConnectionClosedByServer(t *testing.T, conn *websocket.Conn, within time.Duration) { + t.Helper() + _ = conn.SetReadDeadline(time.Now().Add(within)) + _, _, err := conn.ReadMessage() + if err == nil { + t.Fatal("ReadMessage after credential rotation = nil error, want the server to have closed the connection") + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Fatalf("ReadMessage after credential rotation timed out waiting for the server to close (%v), want the server to close immediately", err) + } +} + +// TestAgentStreamTerminatesWhenCredentialRotates pins +// AC-EXECUTORS-CONTROL-OWNERSHIP-002.2 end to end: a live /agent/stream +// connection authenticated under the current credential must be terminated +// by the server the moment that credential is superseded, so a prior holder +// cannot keep consuming an instance's events. Unlike the credentialState +// unit tests (which only prove the Invalidated channel closes) and the +// instanceAuth test above (which only proves the *next* request is +// rejected), this dials a real, already-open stream and proves the server +// actively tears it down rather than merely refusing new connections. +func TestAgentStreamTerminatesWhenCredentialRotates(t *testing.T) { + source := newCredentialState("initial-token") + s := newTestServer(t) + s.SetCredentialSource(source) + httpServer := httptest.NewServer(s.router) + defer httpServer.Close() + + conn := dialTestWSWithAuth(t, httpServer, "initial-token") + defer func() { _ = conn.Close() }() + + // Prove the connection is alive under the current credential before + // rotating, so a failure after rotation can't be blamed on a connection + // that never worked. + resp := sendWSRequest(t, conn, "agent.stderr", nil) + if resp.Type != "response" { + t.Fatalf("pre-rotation request: got type %q, want a response", resp.Type) + } + + if _, _, err := source.Rotate("initial-token"); err != nil { + t.Fatalf("Rotate: %v", err) + } + + assertConnectionClosedByServer(t, conn, 2*time.Second) +} + +// TestAgentStreamTerminatesWhenCredentialRotatesBetweenAcceptAndCapture pins +// Review round 5 finding 1: instanceAuth's accept check and a stream +// handler's own invalidation-channel capture used to be two independent lock +// acquisitions, so a rotation landing in the gap between them could +// authenticate a request against the generation it just superseded while +// handing the handler a channel for the generation that replaced it -- a +// channel that never closes for the rotation that actually invalidated the +// accepted credential. afterInstanceAuthAccepted deterministically lands a +// rotation in exactly that gap; the fix (atomic AcceptsFullWithInvalidation, +// captured by the middleware and read by the handler from gin context) must +// still close the stream. +func TestAgentStreamTerminatesWhenCredentialRotatesBetweenAcceptAndCapture(t *testing.T) { + source := newCredentialState("initial-token") + s := newTestServer(t) + s.SetCredentialSource(source) + httpServer := httptest.NewServer(s.router) + defer httpServer.Close() + + afterInstanceAuthAccepted = func() { + afterInstanceAuthAccepted = nil + if _, _, err := source.Rotate("initial-token"); err != nil { + t.Errorf("Rotate in accept/capture gap: %v", err) + } + } + t.Cleanup(func() { afterInstanceAuthAccepted = nil }) + + conn := dialTestWSWithAuth(t, httpServer, "initial-token") + defer func() { _ = conn.Close() }() + + assertConnectionClosedByServer(t, conn, 2*time.Second) +} + +// TestWorkspaceStreamTerminatesWhenCredentialRotatesBetweenAcceptAndCapture +// mirrors TestAgentStreamTerminatesWhenCredentialRotatesBetweenAcceptAndCapture +// for the workspace stream's own credentialInvalidatedFromContext read. +func TestWorkspaceStreamTerminatesWhenCredentialRotatesBetweenAcceptAndCapture(t *testing.T) { + source := newCredentialState("initial-token") + s := newTestServer(t) + s.SetCredentialSource(source) + httpServer := httptest.NewServer(s.router) + defer httpServer.Close() + + afterInstanceAuthAccepted = func() { + afterInstanceAuthAccepted = nil + if _, _, err := source.Rotate("initial-token"); err != nil { + t.Errorf("Rotate in accept/capture gap: %v", err) + } + } + t.Cleanup(func() { afterInstanceAuthAccepted = nil }) + + conn := dialTestWorkspaceStreamWithAuth(t, httpServer, "initial-token") + defer func() { _ = conn.Close() }() + + // The handler unconditionally sends a "connected" message as its first + // frame before the forwarding loop (and its invalidation check) ever + // runs; consume it before asserting closure, exactly like + // TestWorkspaceStreamTerminatesWhenCredentialRotates does. + var connected types.WorkspaceStreamMessage + if err := conn.ReadJSON(&connected); err != nil { + t.Fatalf("reading connected message: %v", err) + } + + assertConnectionClosedByServer(t, conn, 2*time.Second) +} + +// dialTestWorkspaceStreamWithAuth connects a WebSocket client to the test +// server's /api/v1/workspace/stream endpoint carrying an Authorization +// header, mirroring dialTestWSWithAuth for the agent stream. +func dialTestWorkspaceStreamWithAuth(t *testing.T, server *httptest.Server, token string) *websocket.Conn { + t.Helper() + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/workspace/stream" + header := http.Header{} + header.Set("Authorization", "Bearer "+token) + conn, _, err := websocket.DefaultDialer.Dial(wsURL, header) + if err != nil { + t.Fatalf("failed to dial WebSocket: %v", err) + } + return conn +} + +// TestWorkspaceStreamTerminatesWhenCredentialRotates mirrors +// TestAgentStreamTerminatesWhenCredentialRotates for the workspace stream's +// own select-on-Invalidated arm (workspace.go forwardWorkspaceStream, +// AC-EXECUTORS-CONTROL-OWNERSHIP-002.2). Both streams sit behind the same +// instanceAuth middleware group (server.go) but are forwarded by two +// separate goroutines with two separate select statements, so proving one +// terminates on rotation says nothing about the other. +func TestWorkspaceStreamTerminatesWhenCredentialRotates(t *testing.T) { + source := newCredentialState("initial-token") + s := newTestServer(t) + s.SetCredentialSource(source) + httpServer := httptest.NewServer(s.router) + defer httpServer.Close() + + conn := dialTestWorkspaceStreamWithAuth(t, httpServer, "initial-token") + defer func() { _ = conn.Close() }() + + // The handler sends a "connected" message immediately on upgrade, then + // answers a client ping with a pong -- together they prove the + // connection and its forwarding goroutine are live under the current + // credential before rotating, so a failure after rotation can't be + // blamed on a connection that never worked. + var connected types.WorkspaceStreamMessage + if err := conn.ReadJSON(&connected); err != nil { + t.Fatalf("reading connected message: %v", err) + } + if connected.Type != types.WorkspaceMessageTypeConnected { + t.Fatalf("first message type = %q, want %q", connected.Type, types.WorkspaceMessageTypeConnected) + } + + if err := conn.WriteJSON(types.NewWorkspacePing()); err != nil { + t.Fatalf("writing ping: %v", err) + } + var pong types.WorkspaceStreamMessage + if err := conn.ReadJSON(&pong); err != nil { + t.Fatalf("pre-rotation ping/pong: %v", err) + } + if pong.Type != types.WorkspaceMessageTypePong { + t.Fatalf("pre-rotation response type = %q, want %q", pong.Type, types.WorkspaceMessageTypePong) + } + + if _, _, err := source.Rotate("initial-token"); err != nil { + t.Fatalf("Rotate: %v", err) + } + + assertConnectionClosedByServer(t, conn, 2*time.Second) +} diff --git a/apps/backend/internal/agentctl/server/api/credential_rotation_test.go b/apps/backend/internal/agentctl/server/api/credential_rotation_test.go new file mode 100644 index 0000000000..5833d38e4e --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/credential_rotation_test.go @@ -0,0 +1,467 @@ +package api + +import ( + "net/http/httptest" + "testing" + "time" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/instance" + "github.com/kandev/kandev/internal/common/logger" +) + +// --- credentialState unit tests --- + +// TestCredentialStateRotatePresentingLatestAllocatesNewRotation pins the +// ordinary rotation path: presenting the current fully-authenticating +// credential allocates rotation 1 and a fresh replacement. +func TestCredentialStateRotatePresentingLatestAllocatesNewRotation(t *testing.T) { + c := newCredentialState("initial-token") + + id, replacement, err := c.Rotate("initial-token") + if err != nil { + t.Fatalf("Rotate: %v", err) + } + if id != 1 { + t.Fatalf("rotation id = %d, want 1", id) + } + if replacement == "" || replacement == "initial-token" { + t.Fatalf("replacement = %q, want a fresh non-empty credential", replacement) + } + if got := c.Latest(); got != replacement { + t.Fatalf("Latest() = %q, want the replacement %q", got, replacement) + } +} + +// TestCredentialStateRotateRejectsUnknownCredential pins that a credential +// outside the acceptable set (AC-002.8) is refused. +func TestCredentialStateRotateRejectsUnknownCredential(t *testing.T) { + c := newCredentialState("initial-token") + + if _, _, err := c.Rotate("some-other-token"); err == nil { + t.Fatal("Rotate with unknown credential = nil error, want a rejection") + } +} + +// TestCredentialStateRotateIsIdempotentUnderRetryWithSupersededCredential +// pins AC-EXECUTORS-CONTROL-OWNERSHIP-002.10: presenting the credential the +// highest-numbered rotation superseded, while unconfirmed, returns the same +// rotation again rather than allocating a new one. +func TestCredentialStateRotateIsIdempotentUnderRetryWithSupersededCredential(t *testing.T) { + c := newCredentialState("initial-token") + + id1, replacement1, err := c.Rotate("initial-token") + if err != nil { + t.Fatalf("first Rotate: %v", err) + } + + id2, replacement2, err := c.Rotate("initial-token") + if err != nil { + t.Fatalf("retry Rotate: %v", err) + } + + if id2 != id1 { + t.Fatalf("retry rotation id = %d, want the same id %d (no new allocation)", id2, id1) + } + if replacement2 != replacement1 { + t.Fatalf("retry replacement = %q, want the same replacement %q", replacement2, replacement1) + } +} + +// TestCredentialStateRotateConcurrentCallersConvergeOnOneRotation pins +// AC-EXECUTORS-CONTROL-OWNERSHIP-002.10 under genuine concurrency, not just +// sequential retry: two callers present the same current credential to +// Rotate at effectively the same time. The mutex serializes them, so exactly +// one becomes the real rotation and the other -- now presenting what has +// become the superseded credential -- takes the idempotent-retry branch and +// converges on the identical (rotationID, replacement) pair rather than +// erroring, allocating a second rotation, or corrupting the shared state. +// Neither caller is left believing it owns a distinct credential. +// +// This is a property of the state machine, not a claim that two backends can +// reach it: AC-EXECUTORS-CONTROL-OWNERSHIP-002.4 excludes a second backend at +// startup through the runtime-state ownership lock. The two racers here stand +// in for one backend retrying across a crash, and for the ownership lock +// being wrong. +func TestCredentialStateRotateConcurrentCallersConvergeOnOneRotation(t *testing.T) { + c := newCredentialState("initial-token") + + type result struct { + id int64 + replacement string + err error + } + results := make(chan result, 2) + start := make(chan struct{}) + for i := 0; i < 2; i++ { + go func() { + <-start + id, replacement, err := c.Rotate("initial-token") + results <- result{id: id, replacement: replacement, err: err} + }() + } + close(start) + + first := <-results + second := <-results + + if first.err != nil || second.err != nil { + t.Fatalf("concurrent Rotate errors = %v, %v, want both nil", first.err, second.err) + } + if first.id != 1 || second.id != 1 { + t.Fatalf("rotation ids = %d, %d, want both 1 (no second rotation allocated)", first.id, second.id) + } + if first.replacement == "" || first.replacement != second.replacement { + t.Fatalf("replacements = %q, %q, want both equal and non-empty", first.replacement, second.replacement) + } + if got := c.Latest(); got != first.replacement { + t.Fatalf("Latest() = %q, want the converged replacement %q", got, first.replacement) + } +} + +// TestCredentialStateAcceptsAdoptionOnlyForSupersededUntilConfirmed pins +// AC-002.7: the superseded credential authenticates adoption-only +// operations until confirmed, and the replacement authenticates everything +// throughout. +func TestCredentialStateAcceptsAdoptionOnlyForSupersededUntilConfirmed(t *testing.T) { + c := newCredentialState("initial-token") + _, replacement, err := c.Rotate("initial-token") + if err != nil { + t.Fatalf("Rotate: %v", err) + } + + if !c.AcceptsAdoptionOnly("initial-token") { + t.Fatal("AcceptsAdoptionOnly(superseded) = false before confirm, want true") + } + if !c.AcceptsAdoptionOnly(replacement) { + t.Fatal("AcceptsAdoptionOnly(latest) = false, want true (latest always qualifies)") + } + if c.AcceptsFull("initial-token") { + t.Fatal("AcceptsFull(superseded) = true, want false (superseded never authenticates a full operation)") + } + if !c.AcceptsFull(replacement) { + t.Fatal("AcceptsFull(latest) = false, want true") + } +} + +// TestCredentialStateConfirmDropsSupersededCredential pins that confirming +// the highest-numbered rotation removes the superseded credential from the +// acceptable set entirely. +func TestCredentialStateConfirmDropsSupersededCredential(t *testing.T) { + c := newCredentialState("initial-token") + id, _, err := c.Rotate("initial-token") + if err != nil { + t.Fatalf("Rotate: %v", err) + } + + if err := c.Confirm(id); err != nil { + t.Fatalf("Confirm: %v", err) + } + + if c.AcceptsAdoptionOnly("initial-token") { + t.Fatal("AcceptsAdoptionOnly(superseded) = true after confirm, want false") + } + if _, _, err := c.Rotate("initial-token"); err == nil { + t.Fatal("Rotate(superseded) after confirm = nil error, want a rejection") + } +} + +// TestCredentialStateConfirmOfEarlierRotationIsAcceptedAndHasNoEffect pins +// that a confirmation naming an earlier rotation identifier is accepted but +// does not revoke the credential issued after it (AC-002.6): a delayed or +// duplicated confirmation can never win against a later rotation. +func TestCredentialStateConfirmOfEarlierRotationIsAcceptedAndHasNoEffect(t *testing.T) { + c := newCredentialState("initial-token") + firstID, firstReplacement, err := c.Rotate("initial-token") + if err != nil { + t.Fatalf("first Rotate: %v", err) + } + if _, _, err := c.Rotate(firstReplacement); err != nil { + t.Fatalf("second Rotate: %v", err) + } + + if err := c.Confirm(firstID); err != nil { + t.Fatalf("Confirm(earlier id) = %v, want accepted with no effect", err) + } + + // The second rotation's superseded credential (firstReplacement) must + // still be adoption-only-acceptable: confirming the stale first + // identifier must not have dropped it. + if !c.AcceptsAdoptionOnly(firstReplacement) { + t.Fatal("AcceptsAdoptionOnly(second rotation's superseded credential) = false, want true (stale confirm had no effect)") + } +} + +// TestCredentialStateConfirmOfUnissuedRotationIsRejected pins that naming +// an identifier the control server never allocated is rejected. +func TestCredentialStateConfirmOfUnissuedRotationIsRejected(t *testing.T) { + c := newCredentialState("initial-token") + + if err := c.Confirm(1); err == nil { + t.Fatal("Confirm(never-issued id) = nil error, want a rejection") + } +} + +// TestCredentialStateAcceptableSetCappedAtTwo pins AC-002.8: rotating a +// second time invalidates the credential the *first* rotation superseded, +// even though its rotation was never confirmed. +func TestCredentialStateAcceptableSetCappedAtTwo(t *testing.T) { + c := newCredentialState("initial-token") + _, firstReplacement, err := c.Rotate("initial-token") + if err != nil { + t.Fatalf("first Rotate: %v", err) + } + if _, _, err := c.Rotate(firstReplacement); err != nil { + t.Fatalf("second Rotate: %v", err) + } + + if c.AcceptsAdoptionOnly("initial-token") { + t.Fatal("AcceptsAdoptionOnly(original credential) = true after a second rotation, want false (acceptable set capped at two)") + } +} + +// --- HTTP handler tests --- + +func newRotationTestServer(t *testing.T) (*ControlServer, *httptest.Server, string, int) { + t.Helper() + cfg := &config.Config{AuthToken: "initial-token"} + log := logger.Default() + cs := NewControlServer(cfg, &instance.Manager{}, log) + server := httptest.NewServer(cs.Router()) + t.Cleanup(server.Close) + host, port := parseHostPort(t, server.URL) + return cs, server, host, port +} + +// TestHandleCredentialRotateReturnsNewCredentialAndRenewsOwnership pins the +// end-to-end rotate flow through the real handler and client. +func TestHandleCredentialRotateReturnsNewCredentialAndRenewsOwnership(t *testing.T) { + cs, _, host, port := newRotationTestServer(t) + log := logger.Default() + client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("initial-token")) + + backdateOwnership(cs, time.Hour) + + result, err := client.RotateCredential(t.Context()) + if err != nil { + t.Fatalf("RotateCredential: %v", err) + } + if result.RotationID != 1 { + t.Fatalf("RotationID = %d, want 1", result.RotationID) + } + if result.Credential == "" || result.Credential == "initial-token" { + t.Fatalf("Credential = %q, want a fresh non-empty replacement", result.Credential) + } + if elapsed := cs.ownership.UnownedFor(); elapsed >= time.Minute { + t.Fatalf("UnownedFor after rotate = %v, want well under the backdated hour (rotation should have renewed)", elapsed) + } +} + +// TestSupersededCredentialCannotListInstances pins that the superseded +// credential authenticates only the adoption-only paths, not an ordinary +// instance operation. +func TestSupersededCredentialCannotListInstances(t *testing.T) { + _, _, host, port := newRotationTestServer(t) + log := logger.Default() + rotatingClient := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("initial-token")) + if _, err := rotatingClient.RotateCredential(t.Context()); err != nil { + t.Fatalf("RotateCredential: %v", err) + } + + supersededClient := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("initial-token")) + if _, err := supersededClient.ListInstances(t.Context()); err == nil { + t.Fatal("ListInstances with superseded credential = nil error, want a rejection") + } +} + +// TestSupersededCredentialCanRetryRotateUntilConfirmed pins the +// idempotent-replay path reachable over HTTP: retrying rotate with the +// superseded credential returns the same rotation again. +func TestSupersededCredentialCanRetryRotateUntilConfirmed(t *testing.T) { + _, _, host, port := newRotationTestServer(t) + log := logger.Default() + client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("initial-token")) + + first, err := client.RotateCredential(t.Context()) + if err != nil { + t.Fatalf("first RotateCredential: %v", err) + } + + retryClient := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("initial-token")) + retry, err := retryClient.RotateCredential(t.Context()) + if err != nil { + t.Fatalf("retry RotateCredential: %v", err) + } + + if retry.RotationID != first.RotationID || retry.Credential != first.Credential { + t.Fatalf("retry = %+v, want the same rotation as first %+v", retry, first) + } +} + +// TestHandleCredentialConfirmDropsSupersededCredential pins that after +// confirmation the superseded credential is refused even for rotation +// retries. +func TestHandleCredentialConfirmDropsSupersededCredential(t *testing.T) { + _, _, host, port := newRotationTestServer(t) + log := logger.Default() + client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("initial-token")) + + result, err := client.RotateCredential(t.Context()) + if err != nil { + t.Fatalf("RotateCredential: %v", err) + } + + client.SetAuthToken(result.Credential) + if err := client.ConfirmCredentialRotation(t.Context(), result.RotationID); err != nil { + t.Fatalf("ConfirmCredentialRotation: %v", err) + } + + supersededClient := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("initial-token")) + if _, err := supersededClient.RotateCredential(t.Context()); err == nil { + t.Fatal("RotateCredential with confirmed-away superseded credential = nil error, want a rejection") + } +} + +// TestHandleCredentialConfirmRejectsUnissuedRotationID pins the HTTP-layer +// rejection of an identifier the control server never allocated. +func TestHandleCredentialConfirmRejectsUnissuedRotationID(t *testing.T) { + _, _, host, port := newRotationTestServer(t) + log := logger.Default() + client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("initial-token")) + + if err := client.ConfirmCredentialRotation(t.Context(), 999); err == nil { + t.Fatal("ConfirmCredentialRotation(never-issued id) = nil error, want a rejection") + } +} + +// TestHandleCredentialRotateRefusedAfterShutdownBegun pins the one-way-door +// refusal: rotate is the first step of an adoption attempt and must be +// refused once the unowned-shutdown latch has fired. +func TestHandleCredentialRotateRefusedAfterShutdownBegun(t *testing.T) { + cs, _, host, port := newRotationTestServer(t) + cs.ownership.BeginShutdown() + log := logger.Default() + client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("initial-token")) + + if _, err := client.RotateCredential(t.Context()); err == nil { + t.Fatal("RotateCredential after shutdown began = nil error, want a rejection") + } +} + +// TestHandleCredentialRotateRefusesWhenShutdownLatchesBetweenAcceptAndRenew +// pins Review round 2 finding 5: a concurrent unowned-shutdown reaper can +// latch shutdown in the gap between credentials.Rotate succeeding and +// ownership.Renew being called -- Renew reports false once that happens, and +// the handler must not claim success over a server that is already tearing +// down. Deterministically reproduces that exact window via a test-only hook +// rather than relying on real goroutine timing. +func TestHandleCredentialRotateRefusesWhenShutdownLatchesBetweenAcceptAndRenew(t *testing.T) { + cs, _, host, port := newRotationTestServer(t) + t.Cleanup(func() { afterCredentialRotateAccepted = nil }) + afterCredentialRotateAccepted = func() { cs.ownership.BeginShutdown() } + + log := logger.Default() + client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("initial-token")) + + if _, err := client.RotateCredential(t.Context()); err == nil { + t.Fatal("RotateCredential racing a shutdown latch = nil error, want a rejection") + } +} + +// --- ownership-shutdown operation (AC-EXECUTORS-CONTROL-OWNERSHIP-002.9) --- + +// TestHandleOwnershipShutdownAcceptsTheLatestCredentialWithNoPriorRotation +// pins that shutdown requires no prior adoption or rotation: a backend +// holding only the original bootstrap credential can still invoke it. +func TestHandleOwnershipShutdownAcceptsTheLatestCredentialWithNoPriorRotation(t *testing.T) { + _, _, host, port := newRotationTestServer(t) + log := logger.Default() + client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("initial-token")) + + if err := client.ShutdownControlServer(t.Context()); err != nil { + t.Fatalf("ShutdownControlServer: %v", err) + } +} + +// TestHandleOwnershipShutdownAcceptsSupersededUnconfirmedCredential pins +// AC-002.9's carve-out: the credential a rotation superseded still +// authenticates shutdown, on its adoption-only terms, until confirmed. +func TestHandleOwnershipShutdownAcceptsSupersededUnconfirmedCredential(t *testing.T) { + _, _, host, port := newRotationTestServer(t) + log := logger.Default() + rotatingClient := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("initial-token")) + if _, err := rotatingClient.RotateCredential(t.Context()); err != nil { + t.Fatalf("RotateCredential: %v", err) + } + + supersededClient := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("initial-token")) + if err := supersededClient.ShutdownControlServer(t.Context()); err != nil { + t.Fatalf("ShutdownControlServer with superseded credential: %v", err) + } +} + +// TestHandleOwnershipShutdownRejectsUnknownCredential pins that shutdown is +// still authenticated: a token outside the acceptable set is refused. +func TestHandleOwnershipShutdownRejectsUnknownCredential(t *testing.T) { + _, _, host, port := newRotationTestServer(t) + log := logger.Default() + client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("some-other-token")) + + if err := client.ShutdownControlServer(t.Context()); err == nil { + t.Fatal("ShutdownControlServer with unknown credential = nil error, want a rejection") + } +} + +// TestHandleOwnershipShutdownSignalsShutdownRequested pins that a +// successful shutdown call closes the channel the run loop selects on. +func TestHandleOwnershipShutdownSignalsShutdownRequested(t *testing.T) { + cs, _, host, port := newRotationTestServer(t) + log := logger.Default() + client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("initial-token")) + + if err := client.ShutdownControlServer(t.Context()); err != nil { + t.Fatalf("ShutdownControlServer: %v", err) + } + + select { + case <-cs.ShutdownRequested(): + case <-time.After(time.Second): + t.Fatal("ShutdownRequested() channel not closed within 1s of a successful shutdown call") + } +} + +// TestHandleOwnershipShutdownLatchesTheOneWayDoor pins that shutdown fires +// the same one-way ownership latch as the unowned-period reaper: once +// invoked, a subsequent claim is refused with the shutting-down outcome. +func TestHandleOwnershipShutdownLatchesTheOneWayDoor(t *testing.T) { + cs, _, host, port := newRotationTestServer(t) + log := logger.Default() + client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("initial-token")) + + if err := client.ShutdownControlServer(t.Context()); err != nil { + t.Fatalf("ShutdownControlServer: %v", err) + } + if !cs.ownership.IsShuttingDown() { + t.Fatal("IsShuttingDown() = false after ownership-shutdown call, want true") + } + if err := client.ClaimOwnership(t.Context()); err == nil { + t.Fatal("ClaimOwnership after ownership-shutdown = nil error, want a rejection") + } +} + +// TestHandleOwnershipShutdownIsIdempotent pins that a second shutdown call +// after the first still succeeds without panicking (the channel close is +// guarded by sync.Once) and without re-latching anything. +func TestHandleOwnershipShutdownIsIdempotent(t *testing.T) { + _, _, host, port := newRotationTestServer(t) + log := logger.Default() + client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("initial-token")) + + if err := client.ShutdownControlServer(t.Context()); err != nil { + t.Fatalf("first ShutdownControlServer: %v", err) + } + if err := client.ShutdownControlServer(t.Context()); err != nil { + t.Fatalf("second ShutdownControlServer: %v", err) + } +} diff --git a/apps/backend/internal/agentctl/server/api/identity.go b/apps/backend/internal/agentctl/server/api/identity.go new file mode 100644 index 0000000000..e6954add24 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/identity.go @@ -0,0 +1,38 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// SurvivalCapabilities lists every named capability this build of agentctl +// advertises on GET /identity. Adoption gates on the backend's required set +// being a SUBSET of this list (design 01, "Capability compatibility") -- a +// set comparison rather than a version match, so a future agentctl build can +// add capabilities without breaking older backends, and an older agentctl +// can be correctly refused by a newer backend that requires one it lacks. +var SurvivalCapabilities = []string{"agent-survival.v1"} + +// handleIdentity reports this launch's opaque identity and capability +// scope. It is deliberately exempt from bearer-token auth (see +// NewControlServer): identity retrieval decides adoption compatibility, so +// it cannot itself be gated on the answer. Because it sits below +// authentication it carries nothing beyond what that decision needs, and in +// particular no filesystem path; adoption reads those from +// /api/v1/ownership/details once authenticated. +func (m *ControlServer) handleIdentity(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "server_identity": m.cfg.ServerIdentity, + "capabilities": SurvivalCapabilities, + // unowned_period_ms is this server's own resolved unowned period + // (AC-EXECUTORS-CONTROL-OWNERSHIP-003.2/.7), not the caller's config: + // an adopting backend's local config can disagree with what this + // process actually enforces (e.g. an operator raised + // agentctl.unownedPeriod between restarts), and that mismatch drives + // the adopting backend to renew on a cadence too slow for this + // server's own reaper. This is the only channel that value crosses + // back to an adopting backend. + "unowned_period_ms": m.unownedPeriod.Milliseconds(), + }) +} diff --git a/apps/backend/internal/agentctl/server/api/identity_test.go b/apps/backend/internal/agentctl/server/api/identity_test.go new file mode 100644 index 0000000000..befff6e985 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/identity_test.go @@ -0,0 +1,149 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/instance" + "github.com/kandev/kandev/internal/common/logger" +) + +// TestHandleIdentityIsReachableWithoutAuthAndReportsIdentityAndCapabilities +// pins design 01's "Identity retrieval ... is what decides compatibility, so +// it cannot itself be gated on the answer": /identity must answer even when +// AuthToken is configured and the caller presents no bearer token at all, +// and must report this launch's opaque server identity and the advertised +// capability set. +func TestHandleIdentityIsReachableWithoutAuthAndReportsIdentityAndCapabilities(t *testing.T) { + log := logger.Default() + cfg := &config.Config{ + AuthToken: "some-configured-token", + HomeDir: "/home/kandev-test/.kandev", + ServerIdentity: "server-identity-abc", + DiagnosticLogPath: "/home/kandev-test/.kandev/logs/agentctl-diagnostic.log", + } + mgr := instance.NewManager(cfg, log) + t.Cleanup(func() { _ = mgr.Shutdown(t.Context()) }) + + cs := NewControlServer(cfg, mgr, log) + server := httptest.NewServer(cs.Router()) + defer server.Close() + + resp, err := http.Get(server.URL + "/identity") //nolint:noctx // test-only, hits an ephemeral httptest server + if err != nil { + t.Fatalf("GET /identity: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d (identity must not be gated by auth)", resp.StatusCode, http.StatusOK) + } + + var body struct { + ServerIdentity string `json:"server_identity"` + Capabilities []string `json:"capabilities"` + UnownedPeriodMS int64 `json:"unowned_period_ms"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.ServerIdentity != cfg.ServerIdentity { + t.Errorf("ServerIdentity = %q, want %q", body.ServerIdentity, cfg.ServerIdentity) + } + if len(body.Capabilities) == 0 { + t.Error("Capabilities is empty, want the advertised capability set") + } + // AC-EXECUTORS-CONTROL-OWNERSHIP-003.2 (Review round 3, finding 5): an + // adopting backend must be able to renew ownership on the cadence this + // server actually enforces, not on whatever its own local config + // resolves to -- the two can disagree across a restart that changed + // agentctl.unownedPeriod. /identity is the only channel that value ever + // crosses back to an adopting backend. + if body.UnownedPeriodMS != cs.unownedPeriod.Milliseconds() { + t.Errorf("UnownedPeriodMS = %d, want %d (this server's own resolved unowned period)", body.UnownedPeriodMS, cs.unownedPeriod.Milliseconds()) + } +} + +// TestGetIdentityRoundTripsThroughTheRealClient drives GET /identity through +// the real handler and the real agentctl.ControlClient, matching the +// end-to-end convention established for ListInstances (a hand-fabricated +// client fixture could never have caught that envelope mismatch). +func TestGetIdentityRoundTripsThroughTheRealClient(t *testing.T) { + log := logger.Default() + cfg := &config.Config{ + HomeDir: "/home/kandev-test/.kandev", + ServerIdentity: "server-identity-xyz", + DiagnosticLogPath: "/home/kandev-test/.kandev/logs/agentctl-diagnostic.log", + } + mgr := instance.NewManager(cfg, log) + t.Cleanup(func() { _ = mgr.Shutdown(t.Context()) }) + + cs := NewControlServer(cfg, mgr, log) + server := httptest.NewServer(cs.Router()) + defer server.Close() + host, port := parseHostPort(t, server.URL) + client := agentctl.NewControlClient(host, port, log) + + identity, err := client.GetIdentity(t.Context()) + if err != nil { + t.Fatalf("GetIdentity: %v", err) + } + if identity.ServerIdentity != cfg.ServerIdentity { + t.Errorf("ServerIdentity = %q, want %q", identity.ServerIdentity, cfg.ServerIdentity) + } + if len(identity.Capabilities) == 0 { + t.Error("Capabilities is empty, want the advertised capability set") + } + if identity.UnownedPeriodMS != cs.unownedPeriod.Milliseconds() { + t.Errorf("UnownedPeriodMS = %d, want %d", identity.UnownedPeriodMS, cs.unownedPeriod.Milliseconds()) + } +} + +// TestGetServerDetailsRoundTripsThroughTheRealClientAndRequiresAuth pins the +// endpoint the paths moved to. The filesystem values an adopting backend +// records are still reachable, but only once it has authenticated, so a +// caller that never proved possession of the credential learns nothing about +// this machine's layout. +func TestGetServerDetailsRoundTripsThroughTheRealClientAndRequiresAuth(t *testing.T) { + log := logger.Default() + cfg := &config.Config{ + AuthToken: "details-credential", + HomeDir: "/home/kandev-test/.kandev", + ServerIdentity: "server-identity-details", + DiagnosticLogPath: "/home/kandev-test/.kandev/logs/agentctl-diagnostic.log", + } + mgr := instance.NewManager(cfg, log) + t.Cleanup(func() { _ = mgr.Shutdown(t.Context()) }) + + cs := NewControlServer(cfg, mgr, log) + server := httptest.NewServer(cs.Router()) + defer server.Close() + + unauthenticated, err := http.Get(server.URL + "/api/v1/ownership/details") //nolint:noctx // test-only, hits an ephemeral httptest server + if err != nil { + t.Fatalf("GET /api/v1/ownership/details: %v", err) + } + defer func() { _ = unauthenticated.Body.Close() }() + if unauthenticated.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauthenticated status = %d, want %d", unauthenticated.StatusCode, http.StatusUnauthorized) + } + + host, port := parseHostPort(t, server.URL) + client := agentctl.NewControlClient(host, port, log) + client.SetAuthToken(cfg.AuthToken) + + details, err := client.GetServerDetails(t.Context()) + if err != nil { + t.Fatalf("GetServerDetails: %v", err) + } + if details.HomeDir != cfg.HomeDir { + t.Errorf("HomeDir = %q, want %q", details.HomeDir, cfg.HomeDir) + } + if details.DiagnosticLogPath != cfg.DiagnosticLogPath { + t.Errorf("DiagnosticLogPath = %q, want %q", details.DiagnosticLogPath, cfg.DiagnosticLogPath) + } +} diff --git a/apps/backend/internal/agentctl/server/api/lsp.go b/apps/backend/internal/agentctl/server/api/lsp.go index c92d2b82fd..8f37964fb8 100644 --- a/apps/backend/internal/agentctl/server/api/lsp.go +++ b/apps/backend/internal/agentctl/server/api/lsp.go @@ -146,6 +146,9 @@ func (s *Server) handleLSPStreamWS(c *gin.Context) { } conn.SetReadLimit(protocol.MaxMessageBytes) + releaseFencing := closeOnCredentialInvalidation(c, conn) + defer releaseFencing() + binaryPath, err := s.lspInstaller.BinaryPath(language) if err != nil { autoInstall := lspAutoInstallRequested(c) diff --git a/apps/backend/internal/agentctl/server/api/ownership.go b/apps/backend/internal/agentctl/server/api/ownership.go new file mode 100644 index 0000000000..b5ffbed8ab --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/ownership.go @@ -0,0 +1,128 @@ +package api + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// ownershipState tracks how long it has been since a backend last proved it +// owns this control server (design 01, "Unowned shutdown"). The claim is a +// single explicit operation that both establishes and renews it; the +// bootstrap handshake and a successful credential rotation also renew it. +// It carries no instance identity: a server with zero instances is still +// owned. +type ownershipState struct { + mu sync.Mutex + lastRenewal time.Time + shuttingDown bool +} + +// newOwnershipState starts the clock at construction (process start), so a +// server whose owning backend dies before ever renewing reaps itself from +// its own launch time without any special-cased "never renewed" baseline. +func newOwnershipState() *ownershipState { + return &ownershipState{lastRenewal: time.Now()} +} + +// Renew records a successful claim, handshake, or rotation. Returns false +// without renewing when a shutdown has already begun (design 02's one-way +// door): a claim arriving after that point must be refused, not treated as +// reviving the ownership the shutdown decided to end. +func (o *ownershipState) Renew() bool { + o.mu.Lock() + defer o.mu.Unlock() + if o.shuttingDown { + return false + } + o.lastRenewal = time.Now() + return true +} + +// UnownedFor returns how long it has been since the last successful +// renewal, judged on this process's own clock per design 01 ("never on a +// wall-clock value either side supplies"). +func (o *ownershipState) UnownedFor() time.Duration { + o.mu.Lock() + defer o.mu.Unlock() + return time.Since(o.lastRenewal) +} + +// BeginShutdown latches the one-way unowned-shutdown decision. Returns false +// if a shutdown had already begun, so a caller can distinguish "this call +// began it" from "it was already in progress" without a second check. +func (o *ownershipState) BeginShutdown() bool { + o.mu.Lock() + defer o.mu.Unlock() + if o.shuttingDown { + return false + } + o.shuttingDown = true + return true +} + +// TryBeginShutdownIfUnownedFor atomically checks whether at least period has +// elapsed since the last renewal and, if so, latches the one-way shutdown +// door in the same critical section (AC-EXECUTORS-CONTROL-OWNERSHIP-003.9). +// Performing the elapsed check and the latch under one lock acquisition +// closes the window a separate UnownedFor()-then-BeginShutdown() sequence +// leaves open: a claim's Renew() landing between those two calls would +// succeed (shuttingDown still false) while the reaper's latch still fires +// right after, killing every instance out from under a backend that was just +// told its claim succeeded. Returns true only when this call itself both +// observed the elapsed period and performed the latch. +func (o *ownershipState) TryBeginShutdownIfUnownedFor(period time.Duration) bool { + o.mu.Lock() + defer o.mu.Unlock() + if o.shuttingDown { + return false + } + if time.Since(o.lastRenewal) < period { + return false + } + o.shuttingDown = true + return true +} + +// IsShuttingDown reports whether the one-way shutdown latch has fired. +func (o *ownershipState) IsShuttingDown() bool { + o.mu.Lock() + defer o.mu.Unlock() + return o.shuttingDown +} + +// shuttingDownMessage is the shared one-way-door refusal message for every +// ownership-lifecycle operation (claim, rotate, confirm) once +// AC-EXECUTORS-CONTROL-OWNERSHIP-003.9's shutdown latch has fired. +const shuttingDownMessage = "control server is shutting down" + +// handleOwnershipClaim establishes or renews ownership. Unlike /identity, +// this is a normal authenticated endpoint: it sits above the identity and +// capability negotiation in design 01's gate ordering. +func (m *ControlServer) handleOwnershipClaim(c *gin.Context) { + if !m.ownership.Renew() { + c.JSON(http.StatusConflict, gin.H{errKey: shuttingDownMessage}) + return + } + c.JSON(http.StatusOK, gin.H{lspStatusKey: "claimed"}) +} + +// handleOwnershipShutdown is the ownership-shutdown operation of +// AC-EXECUTORS-CONTROL-OWNERSHIP-002.9: it sits below capability negotiation +// (registered as an adoption-only path, so the superseded credential +// authenticates it too, per AC-002.7) and requires no prior adoption or +// rotation. It only destroys -- it cannot drive an instance or outlive the +// call -- which is why the wider credential set is safe to admit here. +// +// The actual "stop every instance and exit" work happens in the run loop +// (cmd/agentctl/main.go), which already owns that sequence for signal- and +// parent-death-triggered shutdown; this handler only latches the one-way +// shutdown door and signals the loop to run it, mirroring the existing +// parent-liveness trigger rather than duplicating teardown here. +func (m *ControlServer) handleOwnershipShutdown(c *gin.Context) { + m.ownership.BeginShutdown() + m.requestShutdown() + c.JSON(http.StatusOK, gin.H{lspStatusKey: "shutting_down"}) +} diff --git a/apps/backend/internal/agentctl/server/api/ownership_proof.go b/apps/backend/internal/agentctl/server/api/ownership_proof.go new file mode 100644 index 0000000000..3bd38f0074 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/ownership_proof.go @@ -0,0 +1,59 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/ownershipproof" +) + +// ProveOwnership returns one proof per credential currently acceptable to +// this server, replacement first. There are at most two, and the superseded +// one is included while its rotation is unconfirmed because an adopting +// backend that crashed mid-rotation still holds it and must be able to +// adopt again. +func (c *credentialState) ProveOwnership(challenge, binding string) []string { + c.mu.Lock() + defer c.mu.Unlock() + + proofs := []string{ownershipproof.Derive(c.latest, challenge, binding)} + if c.unconfirmed && c.superseded != "" { + proofs = append(proofs, ownershipproof.Derive(c.superseded, challenge, binding)) + } + return proofs +} + +type ownershipProveRequest struct { + Challenge string `json:"challenge"` +} + +// handleOwnershipProve lets an adopting backend verify this process is the +// control server its record names before it sends the credential. It sits +// below authentication because it is what establishes that trust; answering +// it reveals nothing, since a proof is a keyed digest over a challenge the +// caller chose and cannot be inverted to the credential. +func (m *ControlServer) handleOwnershipProve(c *gin.Context) { + if m.ownership.IsShuttingDown() { + c.JSON(http.StatusConflict, gin.H{errKey: shuttingDownMessage}) + return + } + + var req ownershipProveRequest + if err := c.ShouldBindJSON(&req); err != nil || req.Challenge == "" { + c.JSON(http.StatusBadRequest, gin.H{errKey: "missing or invalid challenge"}) + return + } + + c.JSON(http.StatusOK, gin.H{"proofs": m.credentials.ProveOwnership(req.Challenge, m.cfg.HomeDir)}) +} + +// handleOwnershipDetails carries the values an adopting backend records. +// They are filesystem paths, so they are served here, behind authentication, +// rather than on the unauthenticated identity endpoint. +func (m *ControlServer) handleOwnershipDetails(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "home_dir": m.cfg.HomeDir, + "diagnostic_log_path": m.cfg.DiagnosticLogPath, + }) +} diff --git a/apps/backend/internal/agentctl/server/api/ownership_proof_test.go b/apps/backend/internal/agentctl/server/api/ownership_proof_test.go new file mode 100644 index 0000000000..392df55bec --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/ownership_proof_test.go @@ -0,0 +1,195 @@ +package api + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/instance" + "github.com/kandev/kandev/internal/common/logger" +) + +// expectedProof mirrors the derivation an adopting backend performs +// independently. It is written out longhand here rather than calling the +// production helper so the test pins the wire contract, not the +// implementation's agreement with itself. +func expectedProof(credential, challenge, binding string) string { + mac := hmac.New(sha256.New, []byte(credential)) + mac.Write([]byte(challenge)) + mac.Write([]byte{0}) + mac.Write([]byte(binding)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func postProve(t *testing.T, baseURL, challenge string) (int, []string) { + t.Helper() + body, err := json.Marshal(map[string]string{"challenge": challenge}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + resp, err := http.Post(baseURL+"/ownership/prove", "application/json", bytes.NewReader(body)) //nolint:noctx // test-only, ephemeral httptest server + if err != nil { + t.Fatalf("POST /ownership/prove: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return resp.StatusCode, nil + } + var decoded struct { + Proofs []string `json:"proofs"` + } + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + t.Fatalf("decode: %v", err) + } + return resp.StatusCode, decoded.Proofs +} + +// TestOwnershipProveAnswersOnlyWithAValueDerivedFromTheHeldCredential is the +// server half of AC-EXECUTORS-CONTROL-OWNERSHIP-001.10: a control server +// demonstrates it already holds the credential without ever transmitting it, +// and the demonstration is bound to the caller's challenge so a value +// harvested for one attempt is worthless for the next. +func TestOwnershipProveAnswersOnlyWithAValueDerivedFromTheHeldCredential(t *testing.T) { + log := logger.Default() + cfg := &config.Config{ + AuthToken: "the-real-credential", + HomeDir: "/home/kandev-test/.kandev", + ServerIdentity: "server-identity-abc", + } + mgr := instance.NewManager(cfg, log) + t.Cleanup(func() { _ = mgr.Shutdown(t.Context()) }) + server := httptest.NewServer(NewControlServer(cfg, mgr, log).Router()) + defer server.Close() + + status, proofs := postProve(t, server.URL, "challenge-one") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } + if len(proofs) != 1 { + t.Fatalf("len(proofs) = %d, want 1 (only the current credential is acceptable)", len(proofs)) + } + want := expectedProof("the-real-credential", "challenge-one", "/home/kandev-test/.kandev") + if proofs[0] != want { + t.Fatalf("proof = %q, want %q", proofs[0], want) + } + + // The credential itself is never the demonstration. + if proofs[0] == "the-real-credential" { + t.Fatal("the proof is the credential verbatim") + } + + // A different challenge yields a different proof, so a captured proof + // cannot be replayed against the next attempt's challenge. + _, second := postProve(t, server.URL, "challenge-two") + if len(second) != 1 || second[0] == proofs[0] { + t.Fatalf("proof did not vary with the challenge: %v vs %v", proofs, second) + } + + // A server holding a different credential cannot produce this answer. + if proofs[0] == expectedProof("some-other-credential", "challenge-one", "/home/kandev-test/.kandev") { + t.Fatal("proof did not depend on the credential") + } + // Nor can one whose home directory differs, which is bound into the + // derivation precisely because it is no longer disclosed. + if proofs[0] == expectedProof("the-real-credential", "challenge-one", "/some/other/home") { + t.Fatal("proof did not depend on the home directory binding") + } +} + +// TestOwnershipProveCoversEveryAcceptableCredentialDuringTheTwoPhaseWindow: +// AC-EXECUTORS-CONTROL-OWNERSHIP-002.8 keeps at most two credentials +// acceptable while a rotation is unconfirmed, and AC-002.10's replay path +// means the adopting backend may still hold the superseded one. The proof +// must cover both, or that replay would be refused as an impostor. +func TestOwnershipProveCoversEveryAcceptableCredentialDuringTheTwoPhaseWindow(t *testing.T) { + creds := newCredentialState("bootstrap-credential") + + single := creds.ProveOwnership("chal", "/home") + if len(single) != 1 || single[0] != expectedProof("bootstrap-credential", "chal", "/home") { + t.Fatalf("before any rotation, proofs = %v", single) + } + + _, replacement, err := creds.Rotate("bootstrap-credential") + if err != nil { + t.Fatalf("Rotate: %v", err) + } + + both := creds.ProveOwnership("chal", "/home") + if len(both) != 2 { + t.Fatalf("len(proofs) = %d, want 2 while the rotation is unconfirmed", len(both)) + } + wantNew := expectedProof(replacement, "chal", "/home") + wantOld := expectedProof("bootstrap-credential", "chal", "/home") + if both[0] != wantNew || both[1] != wantOld { + t.Fatalf("proofs = %v, want [replacement, superseded]", both) + } + + if err := creds.Confirm(1); err != nil { + t.Fatalf("Confirm: %v", err) + } + after := creds.ProveOwnership("chal", "/home") + if len(after) != 1 || after[0] != wantNew { + t.Fatalf("after confirmation, proofs = %v, want only the replacement", after) + } +} + +// TestOwnershipProveRejectsAnEmptyChallenge: a response must never be +// accepted for a challenge the backend did not generate +// (AC-EXECUTORS-CONTROL-OWNERSHIP-001.10), so a caller that supplies no +// challenge gets no proof rather than one over a fixed empty string. +func TestOwnershipProveRejectsAnEmptyChallenge(t *testing.T) { + log := logger.Default() + cfg := &config.Config{AuthToken: "cred", HomeDir: "/home", ServerIdentity: "id"} + mgr := instance.NewManager(cfg, log) + t.Cleanup(func() { _ = mgr.Shutdown(t.Context()) }) + server := httptest.NewServer(NewControlServer(cfg, mgr, log).Router()) + defer server.Close() + + if status, _ := postProve(t, server.URL, ""); status != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 for an empty challenge", status) + } +} + +// TestIdentityDisclosesNoFilesystemPath is +// AC-EXECUTORS-CONTROL-OWNERSHIP-001.11: the endpoint that sits below +// authentication carries only what the decision to authenticate needs. +func TestIdentityDisclosesNoFilesystemPath(t *testing.T) { + log := logger.Default() + cfg := &config.Config{ + AuthToken: "cred", + HomeDir: "/home/kandev-test/.kandev", + ServerIdentity: "server-identity-abc", + DiagnosticLogPath: "/home/kandev-test/.kandev/logs/agentctl-diagnostic.log", + } + mgr := instance.NewManager(cfg, log) + t.Cleanup(func() { _ = mgr.Shutdown(t.Context()) }) + server := httptest.NewServer(NewControlServer(cfg, mgr, log).Router()) + defer server.Close() + + resp, err := http.Get(server.URL + "/identity") //nolint:noctx // test-only, ephemeral httptest server + if err != nil { + t.Fatalf("GET /identity: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + var payload map[string]any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("decode: %v", err) + } + for _, forbidden := range []string{"home_dir", "diagnostic_log_path"} { + if _, present := payload[forbidden]; present { + t.Errorf("unauthenticated /identity still discloses %q", forbidden) + } + } + for _, required := range []string{"server_identity", "capabilities", "unowned_period_ms"} { + if _, present := payload[required]; !present { + t.Errorf("/identity no longer reports %q, which adoption needs", required) + } + } +} diff --git a/apps/backend/internal/agentctl/server/api/ownership_test.go b/apps/backend/internal/agentctl/server/api/ownership_test.go new file mode 100644 index 0000000000..dd9d493f69 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/ownership_test.go @@ -0,0 +1,297 @@ +package api + +import ( + "net/http/httptest" + "testing" + "time" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/instance" + "github.com/kandev/kandev/internal/common/logger" +) + +// TestOwnershipStateRenewUpdatesLastRenewal pins that Renew advances the +// tracked instant, which the unowned-period reaper (Layer 5.5) will measure +// elapsed time against. +func TestOwnershipStateRenewUpdatesLastRenewal(t *testing.T) { + o := newOwnershipState() + time.Sleep(2 * time.Millisecond) + before := o.UnownedFor() + + if !o.Renew() { + t.Fatal("Renew() = false, want true") + } + after := o.UnownedFor() + + if after >= before { + t.Fatalf("UnownedFor after Renew (%v) >= before (%v), want renewal to reset the elapsed duration", after, before) + } +} + +// TestOwnershipStateNewStartsFromProcessLaunch pins design 01's "A server +// spawned by a backend that then dies before completing the handshake has +// no successful renewal at all, so its period runs from process start and it +// reaps itself without special handling" -- the very first UnownedFor call, +// before any Renew, must already report a small nonzero-or-zero duration +// measured from construction, not an unset/zero-time sentinel that would +// read as "just now" forever or as an enormous elapsed duration. +func TestOwnershipStateNewStartsFromProcessLaunch(t *testing.T) { + o := newOwnershipState() + time.Sleep(2 * time.Millisecond) + + elapsed := o.UnownedFor() + if elapsed <= 0 { + t.Fatalf("UnownedFor() = %v, want a small positive duration measured from construction", elapsed) + } + if elapsed > time.Second { + t.Fatalf("UnownedFor() = %v, want a small duration (construction just happened), not a large one implying a zero-time baseline", elapsed) + } +} + +// TestOwnershipStateBeginShutdownIsOneWay pins the one-way door: once a +// shutdown has begun, Renew must refuse (never resurrect a decided +// shutdown), and BeginShutdown itself is idempotent (a second call reports +// it was already begun rather than restarting the decision). +func TestOwnershipStateBeginShutdownIsOneWay(t *testing.T) { + o := newOwnershipState() + + if !o.BeginShutdown() { + t.Fatal("first BeginShutdown() = false, want true") + } + if o.BeginShutdown() { + t.Fatal("second BeginShutdown() = true, want false (idempotent one-way latch)") + } + if !o.IsShuttingDown() { + t.Fatal("IsShuttingDown() = false after BeginShutdown, want true") + } + if o.Renew() { + t.Fatal("Renew() = true after shutdown began, want false (a decided shutdown must never be reversed)") + } +} + +// TestOwnershipStateTryBeginShutdownIfUnownedForRefusesAfterRecentRenewal +// pins the atomic check-and-latch's core correctness: a renewal that landed +// before the call must be observed by the SAME call that would otherwise +// latch shutdown, closing the TOCTOU window a separate UnownedFor()-then- +// BeginShutdown() sequence leaves open (Review round 1, finding 5). +func TestOwnershipStateTryBeginShutdownIfUnownedForRefusesAfterRecentRenewal(t *testing.T) { + o := newOwnershipState() + if !o.Renew() { + t.Fatal("Renew() = false, want true") + } + + if o.TryBeginShutdownIfUnownedFor(time.Hour) { + t.Fatal("TryBeginShutdownIfUnownedFor() = true immediately after a renewal, want false") + } + if o.IsShuttingDown() { + t.Fatal("IsShuttingDown() = true after a refused attempt, want false") + } +} + +// TestOwnershipStateTryBeginShutdownIfUnownedForLatchesOnceElapsed pins the +// success path: once the period has genuinely elapsed, the call both +// reports true and latches the one-way shutdown door. +func TestOwnershipStateTryBeginShutdownIfUnownedForLatchesOnceElapsed(t *testing.T) { + o := newOwnershipState() + o.mu.Lock() + o.lastRenewal = time.Now().Add(-time.Hour) + o.mu.Unlock() + + if !o.TryBeginShutdownIfUnownedFor(time.Minute) { + t.Fatal("TryBeginShutdownIfUnownedFor() = false once the period elapsed, want true") + } + if !o.IsShuttingDown() { + t.Fatal("IsShuttingDown() = false after a successful latch, want true") + } +} + +// TestOwnershipStateTryBeginShutdownIfUnownedForIsOneWay pins idempotency: +// a second call after the door is already latched must not report success +// again, matching BeginShutdown's existing one-way-door contract. +func TestOwnershipStateTryBeginShutdownIfUnownedForIsOneWay(t *testing.T) { + o := newOwnershipState() + o.mu.Lock() + o.lastRenewal = time.Now().Add(-time.Hour) + o.mu.Unlock() + + if !o.TryBeginShutdownIfUnownedFor(time.Minute) { + t.Fatal("first TryBeginShutdownIfUnownedFor() = false, want true") + } + if o.TryBeginShutdownIfUnownedFor(time.Minute) { + t.Fatal("second TryBeginShutdownIfUnownedFor() = true, want false (one-way latch)") + } +} + +// TestHandleOwnershipClaimRenewsOwnership pins that a successful claim +// renews the tracked ownership instant. The claim operation carries no +// instance identity -- a server with zero instances is still owned. +func TestHandleOwnershipClaimRenewsOwnership(t *testing.T) { + cfg := &config.Config{AuthToken: "test-token"} + log := logger.Default() + cs := NewControlServer(cfg, &instance.Manager{}, log) + server := httptest.NewServer(cs.Router()) + defer server.Close() + host, port := parseHostPort(t, server.URL) + client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("test-token")) + + backdateOwnership(cs, time.Hour) + + if err := client.ClaimOwnership(t.Context()); err != nil { + t.Fatalf("ClaimOwnership: %v", err) + } + + if elapsed := cs.ownership.UnownedFor(); elapsed >= time.Minute { + t.Fatalf("UnownedFor after claim = %v, want well under the backdated hour (claim should have renewed)", elapsed) + } +} + +// backdateOwnership sets a ControlServer's tracked last-renewal instant into +// the past, so a test can assert that a subsequent operation renews it +// without racing HTTP round-trip latency against a tiny real elapsed +// duration. +func backdateOwnership(cs *ControlServer, age time.Duration) { + cs.ownership.mu.Lock() + defer cs.ownership.mu.Unlock() + cs.ownership.lastRenewal = time.Now().Add(-age) +} + +// TestHandleOwnershipClaimRequiresAuth pins that the claim operation sits +// ABOVE identity/capability negotiation in design 01's gate ordering: unlike +// /identity, it is a normal authenticated endpoint. +func TestHandleOwnershipClaimRequiresAuth(t *testing.T) { + cfg := &config.Config{AuthToken: "test-token"} + log := logger.Default() + cs := NewControlServer(cfg, &instance.Manager{}, log) + server := httptest.NewServer(cs.Router()) + defer server.Close() + host, port := parseHostPort(t, server.URL) + client := agentctl.NewControlClient(host, port, log) // no auth token + + if err := client.ClaimOwnership(t.Context()); err == nil { + t.Fatal("ClaimOwnership without auth = nil error, want a rejection") + } +} + +// TestHandleOwnershipClaimRefusedAfterShutdownBegun pins the one-way-door +// refusal at the HTTP layer: a claim arriving after an unowned shutdown has +// begun must be refused with a shutting-down outcome rather than reviving +// ownership underneath a teardown in progress (design 02's failure table). +func TestHandleOwnershipClaimRefusedAfterShutdownBegun(t *testing.T) { + cfg := &config.Config{AuthToken: "test-token"} + log := logger.Default() + cs := NewControlServer(cfg, &instance.Manager{}, log) + cs.ownership.BeginShutdown() + server := httptest.NewServer(cs.Router()) + defer server.Close() + host, port := parseHostPort(t, server.URL) + client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("test-token")) + + if err := client.ClaimOwnership(t.Context()); err == nil { + t.Fatal("ClaimOwnership after shutdown began = nil error, want a rejection") + } +} + +// TestHandleHandshakeRenewsOwnership pins design 01's "two other operations +// renew it: the bootstrap handshake and a successful credential rotation" -- +// a fresh server's first renewal comes from the handshake that mints its +// credential, giving the unowned-period timer a definite start without +// requiring a separate claim call immediately after. +func TestHandleHandshakeRenewsOwnership(t *testing.T) { + cfg := &config.Config{ + AuthToken: "test-generated-token", + BootstrapNonce: "test-nonce", + } + log := logger.Default() + cs := NewControlServer(cfg, &instance.Manager{}, log) + server := httptest.NewServer(cs.Router()) + defer server.Close() + host, port := parseHostPort(t, server.URL) + client := agentctl.NewControlClient(host, port, log) + + backdateOwnership(cs, time.Hour) + + if _, err := client.Handshake(t.Context(), "test-nonce"); err != nil { + t.Fatalf("Handshake: %v", err) + } + + if elapsed := cs.ownership.UnownedFor(); elapsed >= time.Minute { + t.Fatalf("UnownedFor after handshake = %v, want well under the backdated hour (handshake should have renewed)", elapsed) + } +} + +// TestNonRenewingControlOperationsDoNotRenewOwnership pins the negative +// clause of AC-EXECUTORS-CONTROL-OWNERSHIP-003.8: beyond the claim, the +// bootstrap handshake and a successful credential rotation, "no operation +// shall renew ownership: neither an instance operation, nor an open stream, +// nor enumeration". Only the three renewing operations are pinned +// positively elsewhere, which leaves the clause that actually carries the +// guarantee unguarded -- a renewal added to the shared credential +// middleware, or to any instance route, would make a backend that is busy +// but has stopped renewing look present forever and defeat the unowned +// shutdown entirely. Every case drives real HTTP through the router, so the +// middleware is on the path under test rather than bypassed. +// +// The clause's "open stream" arm is structural rather than testable here: +// the streaming routes live on the per-instance Server (server.go), which +// holds no ownership state at all, so no stream can reach this clock. +func TestNonRenewingControlOperationsDoNotRenewOwnership(t *testing.T) { + const backdate = time.Hour + + cfg := &config.Config{AuthToken: "test-token"} + log := logger.Default() + cs := NewControlServer(cfg, &instance.Manager{}, log) + server := httptest.NewServer(cs.Router()) + defer server.Close() + host, port := parseHostPort(t, server.URL) + client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("test-token")) + + // Each call asserts its own outcome before ownership is examined. A + // request that never reached the handler would also leave ownership + // unrenewed, so without these the test would pass vacuously against a + // server that was not listening at all. + cases := []struct { + name string + call func(t *testing.T) + }{ + {"enumeration", func(t *testing.T) { + if _, err := client.ListInstances(t.Context()); err != nil { + t.Fatalf("ListInstances: %v", err) + } + }}, + {"instance operation", func(t *testing.T) { + _, err := client.GetInstance(t.Context(), "no-such-instance") + // The not-found wording is the handler's 404, distinct from the + // "failed to get instance" a transport error produces, so this + // asserts the request was served rather than merely refused. + if err == nil || err.Error() != `instance "no-such-instance" not found` { + t.Fatalf("GetInstance error = %v, want the handler's not-found response", err) + } + }}, + {"ownership details read", func(t *testing.T) { + if _, err := client.GetServerDetails(t.Context()); err != nil { + t.Fatalf("GetServerDetails: %v", err) + } + }}, + {"health", func(t *testing.T) { + if err := client.Health(t.Context()); err != nil { + t.Fatalf("Health: %v", err) + } + }}, + {"identity", func(t *testing.T) { + if _, err := client.GetIdentity(t.Context()); err != nil { + t.Fatalf("GetIdentity: %v", err) + } + }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + backdateOwnership(cs, backdate) + tc.call(t) + if elapsed := cs.ownership.UnownedFor(); elapsed < backdate { + t.Fatalf("UnownedFor() after %s = %v, want >= %v (this operation must not renew ownership)", tc.name, elapsed, backdate) + } + }) + } +} diff --git a/apps/backend/internal/agentctl/server/api/port_proxy.go b/apps/backend/internal/agentctl/server/api/port_proxy.go index 10e2be73c4..2db9f6f8c1 100644 --- a/apps/backend/internal/agentctl/server/api/port_proxy.go +++ b/apps/backend/internal/agentctl/server/api/port_proxy.go @@ -78,7 +78,10 @@ func (s *Server) handlePortProxy(c *gin.Context) { } }() - proxy.ServeHTTP(c.Writer, c.Request) + request, releaseFencing := credentialScopedRequest(c) + defer releaseFencing() + + proxy.ServeHTTP(c.Writer, request) } func (s *Server) createPortProxy(port int) *httputil.ReverseProxy { diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index cd8541cadb..fada65c7d5 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -40,9 +40,26 @@ type Server struct { metricsCollector *metrics.Collector lspInstaller lspInstallerRegistry + // credentialSource, when set via SetCredentialSource, authenticates this + // instance's requests and streams against the control server's single + // rotating credential instead of the static cfg.AuthToken captured at + // construction (design 01 "Single driver", + // AC-EXECUTORS-CONTROL-OWNERSHIP-002.6). Nil preserves the legacy + // static-token behavior every test constructing a Server without a + // running control server alongside it relies on. + credentialSource InstanceCredentialSource + upgrader websocket.Upgrader } +// SetCredentialSource wires this instance server's authentication and +// stream lifetime to the control server's single rotating credential, +// replacing the static per-instance token captured at construction. Call +// once, before the server starts accepting requests. +func (s *Server) SetCredentialSource(src InstanceCredentialSource) { + s.credentialSource = src +} + // NewServer creates a new API server for an agent instance. // If mcpServer is provided, MCP routes will be registered. // If mcpBackendClient is provided, the agent stream becomes bidirectional for MCP. @@ -71,7 +88,7 @@ func NewServer(cfg *config.InstanceConfig, procMgr *process.Manager, mcpServer * // - /health: liveness probe // - /sse, /message, /mcp: MCP endpoints used by the agent subprocess which // runs in the same trust boundary but does not possess the auth token. - s.router.Use(bearerTokenAuth(cfg.AuthToken, "/health", "/sse", "/message", "/mcp")) + s.router.Use(s.instanceAuth(cfg.AuthToken, "/health", "/sse", "/message", "/mcp")) // Validate X-Instance-ID so a client that holds a stale port (because // the previous instance was deleted and the port recycled to a new // instance) gets a clean 404 instead of accidentally configuring or diff --git a/apps/backend/internal/agentctl/server/api/shell_terminal.go b/apps/backend/internal/agentctl/server/api/shell_terminal.go index 28f951c1bb..a6d348df5d 100644 --- a/apps/backend/internal/agentctl/server/api/shell_terminal.go +++ b/apps/backend/internal/agentctl/server/api/shell_terminal.go @@ -75,6 +75,9 @@ func (s *Server) handleShellTerminalStreamWS(c *gin.Context) { } defer func() { _ = conn.Close() }() + releaseFencing := closeOnCredentialInvalidation(c, conn) + defer releaseFencing() + s.logger.Info("terminal shell WebSocket connected", zap.String("terminal_id", terminalID)) // Subscribe to shell output diff --git a/apps/backend/internal/agentctl/server/api/turn_outcome.go b/apps/backend/internal/agentctl/server/api/turn_outcome.go new file mode 100644 index 0000000000..e8be512cef --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/turn_outcome.go @@ -0,0 +1,53 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// handleGetTurnOutcome implements the read side of +// AC-EXECUTORS-SURVIVAL-004.2/.6: a repeatable, non-discarding read of the +// named instance's last retained terminal turn outcome. 404s only when the +// instance itself is unknown; "nothing retained" is a normal 200 answer +// (the case AC-EXECUTORS-SURVIVAL-004.5 maps to "publish as running"). +func (m *ControlServer) handleGetTurnOutcome(c *gin.Context) { + id := c.Param("id") + + outcome, hasOutcome, instanceFound := m.instMgr.PeekTurnOutcome(id) + if !instanceFound { + c.JSON(http.StatusNotFound, gin.H{errKey: instanceNotFoundMessage}) + return + } + if !hasOutcome { + c.JSON(http.StatusOK, gin.H{"retained": false}) + return + } + c.JSON(http.StatusOK, gin.H{ + "retained": true, + "turn_id": outcome.TurnID, + "event": outcome.Event, + }) +} + +type ackTurnOutcomeRequest struct { + TurnID int64 `json:"turn_id" binding:"required"` +} + +// handleAckTurnOutcome implements AC-EXECUTORS-SURVIVAL-004.6's +// acknowledgement: discards the retained outcome only when turn_id names the +// one currently retained. An identifier the server no longer holds, or never +// held, is accepted and changes nothing -- so this always answers 200, +// making a retried acknowledgement safe to send again. +func (m *ControlServer) handleAckTurnOutcome(c *gin.Context) { + id := c.Param("id") + + var req ackTurnOutcomeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{errKey: "turn_id is required"}) + return + } + + m.instMgr.AckTurnOutcome(id, req.TurnID) + c.JSON(http.StatusOK, gin.H{lspStatusKey: "acked"}) +} diff --git a/apps/backend/internal/agentctl/server/api/turn_outcome_test.go b/apps/backend/internal/agentctl/server/api/turn_outcome_test.go new file mode 100644 index 0000000000..5ff8e54051 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/turn_outcome_test.go @@ -0,0 +1,152 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/instance" + "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/agent" +) + +// newTurnOutcomeTestServer wires a real instance.Manager with one real +// instance through a real ControlServer, mirroring the ListInstances +// end-to-end pattern (control_instance_list_test.go): the wire contract for +// turn-outcome retain/peek/ack must be proven against real JSON encoding and +// decoding on both ends, not a hand-written fixture. +func newTurnOutcomeTestServer(t *testing.T) (*instance.Manager, *agentctl.ControlClient, string) { + t.Helper() + log := logger.Default() + mgr := instance.NewManager(&config.Config{ + Ports: config.PortConfig{Base: 0, Max: 0}, + Defaults: config.InstanceDefaults{Protocol: agent.ProtocolACP}, + }, log) + t.Cleanup(func() { _ = mgr.Shutdown(t.Context()) }) + mgr.SetServerFactory(func(*config.InstanceConfig, *process.Manager, *logger.Logger) http.Handler { + return http.NotFoundHandler() + }) + + created, err := mgr.CreateInstance(t.Context(), &instance.CreateRequest{ + WorkspacePath: t.TempDir(), + }) + if err != nil { + t.Fatalf("CreateInstance: %v", err) + } + t.Cleanup(func() { _ = mgr.StopInstance(t.Context(), created.ID) }) + + cs := NewControlServer(&config.Config{}, mgr, log) + server := httptest.NewServer(cs.Router()) + t.Cleanup(server.Close) + host, port := parseHostPort(t, server.URL) + client := agentctl.NewControlClient(host, port, log) + + return mgr, client, created.ID +} + +// TestGetTurnOutcomeReturns404ForUnknownInstance pins that the wire contract +// distinguishes "no such instance" from "instance exists, nothing retained". +func TestGetTurnOutcomeReturns404ForUnknownInstance(t *testing.T) { + _, client, _ := newTurnOutcomeTestServer(t) + + if _, err := client.GetTurnOutcome(t.Context(), "does-not-exist"); err == nil { + t.Fatal("GetTurnOutcome(unknown instance) = nil error, want an error") + } +} + +// TestGetTurnOutcomeReturnsNilWhenNothingRetained pins +// AC-EXECUTORS-SURVIVAL-004.5: a real, live instance with no retained +// terminal outcome yet must answer "nothing retained", not an error. +func TestGetTurnOutcomeReturnsNilWhenNothingRetained(t *testing.T) { + _, client, instanceID := newTurnOutcomeTestServer(t) + + outcome, err := client.GetTurnOutcome(t.Context(), instanceID) + if err != nil { + t.Fatalf("GetTurnOutcome: %v", err) + } + if outcome != nil { + t.Fatalf("outcome = %+v, want nil (nothing retained yet)", outcome) + } +} + +// TestGetTurnOutcomeRoundTripsARetainedOutcomeRepeatedly pins +// AC-EXECUTORS-SURVIVAL-004.2/.6 end to end: a retained outcome round-trips +// through the real HTTP handler and real client, and repeated GETs return +// the identical turn ID and event without discarding it. +func TestGetTurnOutcomeRoundTripsARetainedOutcomeRepeatedly(t *testing.T) { + mgr, client, instanceID := newTurnOutcomeTestServer(t) + + turnID, ok := mgr.RetainTurnOutcome(instanceID, streams.AgentEvent{ + Type: streams.EventTypeComplete, + SessionID: "sess-xyz", + }) + if !ok { + t.Fatal("RetainTurnOutcome() ok = false, want true") + } + + for i := 0; i < 2; i++ { + outcome, err := client.GetTurnOutcome(t.Context(), instanceID) + if err != nil { + t.Fatalf("iteration %d: GetTurnOutcome: %v", i, err) + } + if outcome == nil { + t.Fatalf("iteration %d: outcome = nil, want the retained outcome", i) + } + if outcome.TurnID != turnID { + t.Fatalf("iteration %d: TurnID = %d, want %d", i, outcome.TurnID, turnID) + } + if outcome.Event.SessionID != "sess-xyz" { + t.Fatalf("iteration %d: Event.SessionID = %q, want %q", i, outcome.Event.SessionID, "sess-xyz") + } + } +} + +// TestAckTurnOutcomeClearsTheMatchingOutcome pins the ack half of +// AC-EXECUTORS-SURVIVAL-004.6 end to end: acking the retained turn ID +// discards it, and a subsequent GET reports nothing retained. +func TestAckTurnOutcomeClearsTheMatchingOutcome(t *testing.T) { + mgr, client, instanceID := newTurnOutcomeTestServer(t) + + turnID, _ := mgr.RetainTurnOutcome(instanceID, streams.AgentEvent{Type: streams.EventTypeComplete}) + + if err := client.AckTurnOutcome(t.Context(), instanceID, turnID); err != nil { + t.Fatalf("AckTurnOutcome: %v", err) + } + + outcome, err := client.GetTurnOutcome(t.Context(), instanceID) + if err != nil { + t.Fatalf("GetTurnOutcome after ack: %v", err) + } + if outcome != nil { + t.Fatalf("outcome after ack = %+v, want nil", outcome) + } +} + +// TestAckTurnOutcomeWithUnrelatedIDIsSafeAndChangesNothing pins that acking +// an identifier the server never issued (or already discarded) is accepted +// -- so a retried acknowledgement is always safe -- and does not disturb a +// different outcome that is still retained. +func TestAckTurnOutcomeWithUnrelatedIDIsSafeAndChangesNothing(t *testing.T) { + mgr, client, instanceID := newTurnOutcomeTestServer(t) + + turnID, _ := mgr.RetainTurnOutcome(instanceID, streams.AgentEvent{Type: streams.EventTypeComplete}) + + if err := client.AckTurnOutcome(t.Context(), instanceID, turnID+999); err != nil { + t.Fatalf("AckTurnOutcome(unrelated id): %v", err) + } + + outcome, err := client.GetTurnOutcome(t.Context(), instanceID) + if err != nil { + t.Fatalf("GetTurnOutcome: %v", err) + } + if outcome == nil { + t.Fatal("outcome = nil after an unrelated ack, want the original outcome still retained") + } + if outcome.TurnID != turnID { + t.Fatalf("TurnID = %d, want %d (unaffected by the unrelated ack)", outcome.TurnID, turnID) + } +} diff --git a/apps/backend/internal/agentctl/server/api/unowned_reaper.go b/apps/backend/internal/agentctl/server/api/unowned_reaper.go new file mode 100644 index 0000000000..1524930fb6 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/unowned_reaper.go @@ -0,0 +1,61 @@ +package api + +import ( + "time" + + "go.uber.org/zap" +) + +// unownedReaperScanInterval is how often the reaper checks elapsed unowned +// time. It is a polling granularity, not an operator-facing setting -- like +// F71's log-sink bounds, it is a fixed implementation constant, not one of +// the five numeric tunables in the configuration surface. +const unownedReaperScanInterval = 15 * time.Second + +// StartUnownedReaper starts the background goroutine that stops every +// instance and exits once no ownership claim has been current for the +// resolved unowned period (AC-EXECUTORS-CONTROL-OWNERSHIP-003.1). It fires +// the SAME one-way shutdown latch and ShutdownRequested signal the +// ownership-shutdown operation uses (see ownership.go), so the run loop +// only needs one trigger for both a deliberate shutdown call and a timed-out +// one. +// +// Callers must not start this until the launch is actually running with the +// agent-survival capability engaged AND something is periodically renewing +// ownership -- the backend-side claim loop in +// internal/agent/runtime/lifecycle: starting it unconditionally on every +// agentctl launch would self-terminate a perfectly healthy attached instance +// once the period elapses, since nothing renews ownership without that +// claim loop. Gated by cmd/agentctl/main.go's startUnownedReaperIfEnabled. +func (m *ControlServer) StartUnownedReaper() { + m.reaperWG.Add(1) + go m.runUnownedReaper(m.unownedPeriod, unownedReaperScanInterval) +} + +// StopUnownedReaper signals the reaper goroutine to exit and waits for it +// to drain. Safe to call even if StartUnownedReaper was never called. +func (m *ControlServer) StopUnownedReaper() { + m.reaperStopOnce.Do(func() { close(m.reaperStop) }) + m.reaperWG.Wait() +} + +func (m *ControlServer) runUnownedReaper(period, interval time.Duration) { + defer m.reaperWG.Done() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-m.reaperStop: + return + case <-ticker.C: + if m.decideUnownedShutdown(period) { + m.requestShutdown() + m.logger.Warn("no ownership claim renewed within the unowned period, shutting down", + zap.Duration("unowned_period", period)) + return + } + } + } +} diff --git a/apps/backend/internal/agentctl/server/api/unowned_reaper_test.go b/apps/backend/internal/agentctl/server/api/unowned_reaper_test.go new file mode 100644 index 0000000000..a611ce0b96 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/unowned_reaper_test.go @@ -0,0 +1,159 @@ +package api + +import ( + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/instance" + "github.com/kandev/kandev/internal/common/logger" +) + +// resolveUnownedPeriod's own coverage (AC-EXECUTORS-CONTROL-OWNERSHIP-003.4/ +// .6/.7) moved to internal/common/ownershipperiod, which both this package +// and the backend's ownership-renewal loop now import, so the computation +// cannot drift between the two sides that must agree on it. + +// --- reaper goroutine lifecycle --- + +func newUnownedReaperTestServer(t *testing.T) *ControlServer { + t.Helper() + cfg := &config.Config{AuthToken: "test-token"} + cs := NewControlServer(cfg, &instance.Manager{}, logger.Default()) + t.Cleanup(cs.StopUnownedReaper) + return cs +} + +// TestUnownedReaperFiresShutdownAfterPeriodElapses pins +// AC-EXECUTORS-CONTROL-OWNERSHIP-003.1/.9: once no ownership claim has been +// current for the resolved period, the reaper latches the one-way shutdown +// door and signals the run loop through the SAME mechanism the +// ownership-shutdown operation uses, not a second path. +func TestUnownedReaperFiresShutdownAfterPeriodElapses(t *testing.T) { + cs := newUnownedReaperTestServer(t) + backdateOwnership(cs, time.Hour) + + cs.reaperWG.Add(1) + go cs.runUnownedReaper(20*time.Millisecond, 5*time.Millisecond) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cs.ownership.IsShuttingDown() { + break + } + time.Sleep(5 * time.Millisecond) + } + + if !cs.ownership.IsShuttingDown() { + t.Fatal("ownership.IsShuttingDown() = false after the unowned period elapsed, want true") + } + + // Latching the one-way door and closing the signal channel are separate + // steps, so the signal is awaited rather than sampled once. + select { + case <-cs.ShutdownRequested(): + case <-time.After(2 * time.Second): + t.Fatal("ShutdownRequested() channel not closed after the reaper fired") + } +} + +// TestUnownedReaperDoesNotFireWhileOwnershipIsRenewed pins that a +// continuously-renewed ownership claim keeps the reaper quiet: it must +// measure elapsed time since the last renewal, not since the reaper itself +// started. +func TestUnownedReaperDoesNotFireWhileOwnershipIsRenewed(t *testing.T) { + cs := newUnownedReaperTestServer(t) + + cs.reaperWG.Add(1) + go cs.runUnownedReaper(200*time.Millisecond, 10*time.Millisecond) + + renewDeadline := time.Now().Add(400 * time.Millisecond) + for time.Now().Before(renewDeadline) { + cs.ownership.Renew() + time.Sleep(5 * time.Millisecond) + } + + if cs.ownership.IsShuttingDown() { + t.Fatal("ownership.IsShuttingDown() = true despite continuous renewal, want false") + } + select { + case <-cs.ShutdownRequested(): + t.Fatal("ShutdownRequested() channel closed despite continuous renewal") + default: + } +} + +// TestStopUnownedReaperSafeWithoutStart pins that Stop is a safe no-op when +// the reaper was never started, mirroring the goroutine-ownership +// convention's idempotent-on-both-ends requirement. +func TestStopUnownedReaperSafeWithoutStart(t *testing.T) { + cs := newUnownedReaperTestServer(t) + + done := make(chan struct{}) + go func() { + cs.StopUnownedReaper() + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("StopUnownedReaper() blocked when the reaper was never started") + } +} + +// TestStopUnownedReaperIsIdempotent pins that calling Stop twice after a real +// Start does not panic or deadlock. +func TestStopUnownedReaperIsIdempotent(t *testing.T) { + cs := newUnownedReaperTestServer(t) + cs.reaperWG.Add(1) + go cs.runUnownedReaper(time.Hour, 5*time.Millisecond) + + cs.StopUnownedReaper() + cs.StopUnownedReaper() +} + +// TestRunUnownedReaperUsesInjectedDecider pins Review round 2 finding 8: the +// reaper loop must call the injected decideUnownedShutdown hook -- the same +// atomic check-and-latch ownership.TryBeginShutdownIfUnownedFor is wired to +// by default -- rather than a hardcoded call to that method. Overriding the +// hook to a call-counting stub that never fires proves the loop actually +// consults it every tick instead of a reintroduced separate +// check-then-latch pair that would leave this package green even with the +// injection point deleted. +func TestRunUnownedReaperUsesInjectedDecider(t *testing.T) { + cs := newUnownedReaperTestServer(t) + + var calls int + var mu sync.Mutex + cs.decideUnownedShutdown = func(time.Duration) bool { + mu.Lock() + defer mu.Unlock() + calls++ + return false + } + + cs.reaperWG.Add(1) + go cs.runUnownedReaper(time.Hour, 5*time.Millisecond) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + n := calls + mu.Unlock() + if n >= 3 { + break + } + time.Sleep(5 * time.Millisecond) + } + + mu.Lock() + defer mu.Unlock() + if calls < 3 { + t.Fatalf("decideUnownedShutdown called %d times, want at least 3", calls) + } + if cs.ownership.IsShuttingDown() { + t.Fatal("ownership.IsShuttingDown() = true, want false since the injected decider always returned false") + } +} diff --git a/apps/backend/internal/agentctl/server/api/vscode_proxy.go b/apps/backend/internal/agentctl/server/api/vscode_proxy.go index 9bab1dfbe7..a12d68a45f 100644 --- a/apps/backend/internal/agentctl/server/api/vscode_proxy.go +++ b/apps/backend/internal/agentctl/server/api/vscode_proxy.go @@ -80,5 +80,8 @@ func (s *Server) handleVscodeProxy(c *gin.Context) { } }() - proxy.ServeHTTP(c.Writer, c.Request) + request, releaseFencing := credentialScopedRequest(c) + defer releaseFencing() + + proxy.ServeHTTP(c.Writer, request) } diff --git a/apps/backend/internal/agentctl/server/api/workspace.go b/apps/backend/internal/agentctl/server/api/workspace.go index 84c5a56688..9ddada8ba2 100644 --- a/apps/backend/internal/agentctl/server/api/workspace.go +++ b/apps/backend/internal/agentctl/server/api/workspace.go @@ -92,21 +92,32 @@ func (s *Server) handleWorkspaceStreamWS(c *gin.Context) { } go s.handleWorkspaceStreamInput(stream, shellWriter, done) - s.forwardWorkspaceStream(stream, sub, shellOutputCh, done) + // AC-EXECUTORS-CONTROL-OWNERSHIP-002.2: the invalidation channel comes + // from instanceAuth's context value, captured atomically with this + // request's own accept check -- not a fresh Invalidated() call here, + // which would be a second, independent lock acquisition racing a + // concurrent rotation. + s.forwardWorkspaceStream(stream, sub, shellOutputCh, done, credentialInvalidatedFromContext(c)) } // forwardWorkspaceStream forwards workspace events and shell output to the -// WebSocket until the client goes away or the handler shuts down. +// WebSocket until the client goes away or the handler shuts down. invalidated +// is nil when credentialSource is unset, which never fires in a select -- +// legacy behavior for every existing test constructing a Server without a +// control server alongside it. func (s *Server) forwardWorkspaceStream( stream *workspaceStreamConn, sub types.WorkspaceStreamSubscriber, shellOutputCh chan []byte, done <-chan struct{}, + invalidated <-chan struct{}, ) { for { select { case <-done: return + case <-invalidated: + return case msg, ok := <-sub: if !ok { return diff --git a/apps/backend/internal/agentctl/server/api/workspace_rescan.go b/apps/backend/internal/agentctl/server/api/workspace_rescan.go index 29cf489afd..9f9003e2a0 100644 --- a/apps/backend/internal/agentctl/server/api/workspace_rescan.go +++ b/apps/backend/internal/agentctl/server/api/workspace_rescan.go @@ -11,6 +11,11 @@ import ( // out to satisfy goconst's repeated-string rule across the api package. const errKey = "error" +// instanceNotFoundMessage is the shared 404 body for every instance-scoped +// handler that looks an instance up by ID. Hoisted out to satisfy goconst's +// repeated-string rule across the api package. +const instanceNotFoundMessage = "instance not found" + // RescanWorkspaceRequest is the body for POST /api/v1/workspace/rescan. // // work_dir is optional. When supplied, the manager updates its tracking diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index 56bd1a9755..b46f45d864 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -36,6 +36,17 @@ import ( const ( windowsOS = "windows" pathEnvKey = "PATH" + + defaultUnownedPeriod = 10 * time.Minute + defaultDetachedEventLimit = 100 + + // diagnosticLogFileName is the fixed name of agentctl's own detached + // diagnostic log file (AC-EXECUTORS-SURVIVAL-001.3). It lives under + // HomeDir/logs alongside the backend's own "backend-logs.log" and the + // per-session ACP debug logs under logs/acp/ -- a distinct file, so two + // processes never rotate the same file or share one set of rollover + // journals. + diagnosticLogFileName = "agentctl-diagnostic.log" ) // Config is the agentctl configuration. @@ -111,6 +122,50 @@ type Config struct { // OTLPEndpoint is the resolved endpoint used by agentctl transport tracing. OTLPEndpoint string + // UnownedPeriod is how long this control server tolerates having no + // owning backend before it stops its instances and exits. Sourced from + // KANDEV_ACP_UNOWNED_PERIOD (default 10m). Only meaningful when the + // agent-survival capability is active; unused otherwise. + UnownedPeriod time.Duration + + // DetachedEventLimit bounds the per-instance retained-event count while + // this control server has no owning backend attached. Sourced from + // KANDEV_ACP_DETACHED_EVENT_LIMIT (default 100). Only meaningful when the + // agent-survival capability is active; unused otherwise. + DetachedEventLimit int + + // HomeDir is the resolved Kandev root directory this agentctl process was + // started for. It is installation identity: the adopting backend compares + // it against its own resolved home before ever authenticating, per design + // 01's "Ownership identity and credential". Reported on GET /identity. + HomeDir string + + // ServerIdentity is an opaque value generated once per process launch, + // echoed on GET /identity and compared by the adopting backend, never + // logged. It answers "is this the process a previous launch started", + // distinct from AuthToken which answers "is the caller authorized". + ServerIdentity string + + // DiagnosticLogPath is the durable file agentctl's own diagnostic + // output is written to while it may be running detached from any + // backend (AC-EXECUTORS-SURVIVAL-001.3). Derived from HomeDir with a + // fixed filename -- the sink adds no configuration key and no + // environment variable of its own. Empty only when HomeDir itself + // could not be resolved. Echoed on GET /identity so an adopting or + // freshly-spawning backend records it into the control-server record + // without independently re-deriving the same path formula. + DiagnosticLogPath string + + // AgentSurvivalEnabled is whether the agent-survival capability is + // engaged for this launch, copied directly from the managed startup + // contract (never "unresolved" the way UnownedPeriod/DetachedEventLimit + // can be -- a managed launch always states it, and false is the + // legitimate "disabled" answer). An unmanaged/direct launch (Load(), + // no startup contract) always resolves this to false. Gates + // StartUnownedReaper, the detached diagnostic log sink switch, and the + // launcher-side kill-path removal (Layer 5.9). + AgentSurvivalEnabled bool + // mu protects BootstrapNonce from concurrent access during handshake. mu sync.Mutex } @@ -229,6 +284,11 @@ type InstanceConfig struct { // inherited from the server startup contract. NotificationQueueCapacity int + // DetachedEventLimit bounds the per-instance retained-event count + // (AC-EXECUTORS-SURVIVAL-001.6), inherited from the server startup + // contract. It sizes the process manager's updates channel buffer. + DetachedEventLimit int + // SessionID is the session ID for this agent instance (used in MCP tool calls) SessionID string @@ -355,11 +415,28 @@ func load(startup *commonconfig.AgentctlStartupConfig) *Config { idleReaperInterval := getEnvDuration("KANDEV_ACP_IDLE_REAPER_INTERVAL", time.Minute) notificationQueueCapacity := getEnvInt("KANDEV_ACP_NOTIF_QUEUE", 131072) otlpEndpoint := getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "") + unownedPeriod := getEnvDuration("KANDEV_ACP_UNOWNED_PERIOD", defaultUnownedPeriod) + detachedEventLimit := getEnvInt("KANDEV_ACP_DETACHED_EVENT_LIMIT", defaultDetachedEventLimit) + homeDir := resolveHomeDir() + agentSurvivalEnabled := false if startup != nil { idleTimeout = startup.IdleTimeout idleReaperInterval = startup.IdleReaperInterval notificationQueueCapacity = startup.NotificationQueueCapacity otlpEndpoint = startup.OTLPEndpoint + // Zero means the caller did not resolve these (an older backend, or + // one built before agent survival existed) — keep the env/built-in + // value already computed above rather than adopting zero. + if startup.UnownedPeriod != 0 { + unownedPeriod = startup.UnownedPeriod + } + if startup.DetachedEventLimit != 0 { + detachedEventLimit = startup.DetachedEventLimit + } + // Unlike the two tunables above, false is a meaningful resolved + // answer here (every managed launch states it), so it is copied + // unconditionally rather than guarded by a zero-value check. + agentSurvivalEnabled = startup.AgentSurvivalEnabled } cfg := &Config{ @@ -387,6 +464,12 @@ func load(startup *commonconfig.AgentctlStartupConfig) *Config { IdleReaperInterval: idleReaperInterval, NotificationQueueCapacity: notificationQueueCapacity, OTLPEndpoint: otlpEndpoint, + UnownedPeriod: unownedPeriod, + DetachedEventLimit: detachedEventLimit, + HomeDir: homeDir, + ServerIdentity: generateSelfToken(), + DiagnosticLogPath: resolveDiagnosticLogPath(homeDir), + AgentSurvivalEnabled: agentSurvivalEnabled, } // Bootstrap nonce mode: agentctl generates its own token and the backend @@ -459,6 +542,37 @@ func (c *Config) ConsumeNonce(nonce string) string { return c.AuthToken } +// resolveHomeDir determines the Kandev root directory this agentctl process +// was started for, honoring KANDEV_HOME_DIR (already the Kandev root) ahead +// of $HOME so dev/e2e isolation and Docker/K8s roots resolve consistently. +// This duplicates adapter/transport/shared.resolveACPLogDir's precedence +// rather than importing it: config is a leaf package with no dependency on +// adapter/transport/shared, and pulling that dependency in for one four-line +// primitive would invert the layering. +func resolveHomeDir() string { + if home := os.Getenv("KANDEV_HOME_DIR"); home != "" { + return home + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, ".kandev") + } + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "" +} + +// resolveDiagnosticLogPath derives agentctl's own detached diagnostic log +// path from the resolved home directory (AC-EXECUTORS-SURVIVAL-001.3): a +// fixed filename under HomeDir/logs, not an operator-configurable value. +// Empty when homeDir itself could not be resolved. +func resolveDiagnosticLogPath(homeDir string) string { + if homeDir == "" { + return "" + } + return filepath.Join(homeDir, "logs", diagnosticLogFileName) +} + // generateSelfToken creates a cryptographically random 32-byte hex-encoded token. func generateSelfToken() string { b := make([]byte, 32) @@ -484,6 +598,7 @@ func (c *Config) NewInstanceConfig(port int, overrides *InstanceOverrides) *Inst LogFormat: c.LogFormat, ProcessBufferMaxBytes: c.Defaults.ProcessBufferMaxBytes, NotificationQueueCapacity: c.NotificationQueueCapacity, + DetachedEventLimit: c.DetachedEventLimit, VscodeCommand: c.VscodeCommand, McpMode: "task", AuthToken: c.AuthToken, diff --git a/apps/backend/internal/agentctl/server/config/config_test.go b/apps/backend/internal/agentctl/server/config/config_test.go index c9b3903dc8..92362471c2 100644 --- a/apps/backend/internal/agentctl/server/config/config_test.go +++ b/apps/backend/internal/agentctl/server/config/config_test.go @@ -41,6 +41,49 @@ func TestLoadWithStartupUsesExplicitManagedValues(t *testing.T) { } } +// TestLoadWithStartupPropagatesAgentSurvivalEnabled pins that +// Config.AgentSurvivalEnabled is copied directly from the managed contract in +// both directions -- unlike UnownedPeriod/DetachedEventLimit, false is not +// "unresolved" here (a managed launch always sets Configured=true), so it +// must not be treated as "keep agentctl's own default". +func TestLoadWithStartupPropagatesAgentSurvivalEnabled(t *testing.T) { + base := commonconfig.AgentctlStartupConfig{ + Configured: true, + IdleReaperInterval: time.Minute, + NotificationQueueCapacity: 4096, + } + + enabled := base + enabled.AgentSurvivalEnabled = true + cfg, err := LoadWithStartup(enabled) + if err != nil { + t.Fatalf("LoadWithStartup: %v", err) + } + if !cfg.AgentSurvivalEnabled { + t.Fatal("AgentSurvivalEnabled = false, want true when the startup contract enables it") + } + + disabled := base + disabled.AgentSurvivalEnabled = false + cfg, err = LoadWithStartup(disabled) + if err != nil { + t.Fatalf("LoadWithStartup: %v", err) + } + if cfg.AgentSurvivalEnabled { + t.Fatal("AgentSurvivalEnabled = true, want false when the startup contract disables it") + } +} + +// TestLoadWithoutStartupLeavesAgentSurvivalDisabled pins that a legacy/direct +// (unmanaged) launch -- Load(), no startup contract -- never engages the +// capability, matching AC-EXECUTORS-SURVIVAL-005.2's "defaults disabled". +func TestLoadWithoutStartupLeavesAgentSurvivalDisabled(t *testing.T) { + cfg := Load() + if cfg.AgentSurvivalEnabled { + t.Fatal("AgentSurvivalEnabled = true from Load() with no startup contract, want false") + } +} + func TestNewInstanceConfigNormalizesMcpProviders(t *testing.T) { cfg := (&Config{}).NewInstanceConfig(0, &InstanceOverrides{ McpProviders: []string{" GITLAB ", "unsupported", "github", "github"}, diff --git a/apps/backend/internal/agentctl/server/config/diagnostic_log_test.go b/apps/backend/internal/agentctl/server/config/diagnostic_log_test.go new file mode 100644 index 0000000000..b52918b55c --- /dev/null +++ b/apps/backend/internal/agentctl/server/config/diagnostic_log_test.go @@ -0,0 +1,63 @@ +package config + +import ( + "path/filepath" + "testing" +) + +// TestResolveDiagnosticLogPath pins AC-EXECUTORS-SURVIVAL-001.3: the +// diagnostic log lives under HomeDir/logs with a fixed filename, and is +// empty only when HomeDir itself could not be resolved -- there is no +// separate configuration key or environment variable for this path. +func TestResolveDiagnosticLogPath(t *testing.T) { + tests := []struct { + name string + homeDir string + want string + }{ + { + name: "derives from home dir", + homeDir: "/home/kandev-test/.kandev", + want: filepath.Join("/home/kandev-test/.kandev", "logs", "agentctl-diagnostic.log"), + }, + { + name: "empty home dir yields empty path", + homeDir: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolveDiagnosticLogPath(tt.homeDir); got != tt.want { + t.Fatalf("resolveDiagnosticLogPath(%q) = %q, want %q", tt.homeDir, got, tt.want) + } + }) + } +} + +// TestResolveDiagnosticLogPathDiffersFromBackendLogFile pins the "different +// file from the one the backend writes its own logs to" half of +// AC-EXECUTORS-SURVIVAL-001.3 -- two processes rotating one file corrupts +// both, so the filename must never collide with the backend's own +// "backend-logs.log". +func TestResolveDiagnosticLogPathDiffersFromBackendLogFile(t *testing.T) { + got := resolveDiagnosticLogPath("/home/kandev-test/.kandev") + if filepath.Base(got) == "backend-logs.log" { + t.Fatalf("resolveDiagnosticLogPath() = %q, filename collides with the backend's own log file", got) + } +} + +// TestLoadDerivesDiagnosticLogPathFromTheSameHomeDir pins that Config.HomeDir +// and Config.DiagnosticLogPath are derived from a single resolution, so they +// can never disagree about which installation they belong to. +func TestLoadDerivesDiagnosticLogPathFromTheSameHomeDir(t *testing.T) { + cfg := Load() + if cfg.HomeDir == "" { + t.Skip("HomeDir could not be resolved in this environment") + } + want := filepath.Join(cfg.HomeDir, "logs", "agentctl-diagnostic.log") + if cfg.DiagnosticLogPath != want { + t.Fatalf("DiagnosticLogPath = %q, want %q (derived from HomeDir %q)", cfg.DiagnosticLogPath, want, cfg.HomeDir) + } +} diff --git a/apps/backend/internal/agentctl/server/config/identity_test.go b/apps/backend/internal/agentctl/server/config/identity_test.go new file mode 100644 index 0000000000..65a329cdae --- /dev/null +++ b/apps/backend/internal/agentctl/server/config/identity_test.go @@ -0,0 +1,55 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// TestLoadPopulatesServerIdentity pins that every launch gets a non-empty, +// unique opaque identity value -- the "opaque per-launch value echoed on an +// identity endpoint" design 01 describes -- regardless of whether bootstrap +// auth is configured. +func TestLoadPopulatesServerIdentity(t *testing.T) { + first := load(nil) + second := load(nil) + + if first.ServerIdentity == "" { + t.Fatal("ServerIdentity is empty, want a generated value") + } + if first.ServerIdentity == second.ServerIdentity { + t.Fatal("ServerIdentity is identical across two loads, want distinct per-launch values") + } +} + +// TestLoadResolvesHomeDirFromKandevHomeDirEnv pins that HomeDir honors +// KANDEV_HOME_DIR directly (already the Kandev root), matching +// adapter/transport/shared.resolveACPLogDir's precedence so agentctl reports +// the same installation identity everywhere it resolves the Kandev home. +func TestLoadResolvesHomeDirFromKandevHomeDirEnv(t *testing.T) { + t.Setenv("KANDEV_HOME_DIR", "/custom/kandev/home") + + cfg := load(nil) + + if cfg.HomeDir != "/custom/kandev/home" { + t.Fatalf("HomeDir = %q, want %q", cfg.HomeDir, "/custom/kandev/home") + } +} + +// TestLoadResolvesHomeDirFallsBackToUserHomeDir pins the fallback when +// KANDEV_HOME_DIR is unset: the user's home directory plus ".kandev". +func TestLoadResolvesHomeDirFallsBackToUserHomeDir(t *testing.T) { + t.Setenv("KANDEV_HOME_DIR", "") + + userHome, err := os.UserHomeDir() + if err != nil { + t.Skipf("no user home dir available in this environment: %v", err) + } + + cfg := load(nil) + + want := filepath.Join(userHome, ".kandev") + if cfg.HomeDir != want { + t.Fatalf("HomeDir = %q, want %q", cfg.HomeDir, want) + } +} diff --git a/apps/backend/internal/agentctl/server/instance/instance.go b/apps/backend/internal/agentctl/server/instance/instance.go index bf4125730c..707e302d42 100644 --- a/apps/backend/internal/agentctl/server/instance/instance.go +++ b/apps/backend/internal/agentctl/server/instance/instance.go @@ -16,6 +16,15 @@ import ( type processManager interface { CloseAdmission() StopForTeardown(context.Context) error + // WorkspaceSourceRoots returns the live, current source-root allowlist a + // rescan/rebind may have changed since creation (AC-EXECUTORS-SURVIVAL- + // 002.14's "workspace source roots" reconstruction row). + WorkspaceSourceRoots() []string + // GetSessionID returns the provider's own session identity, held by the + // live adapter (AC-EXECUTORS-SURVIVAL-002.14's "provider session + // identity" reconstruction row). Empty before the agent's session/new or + // session/load completes. + GetSessionID() string } // Instance represents a single agent instance running as a subprocess. @@ -42,6 +51,13 @@ type Instance struct { // CreatedAt is the timestamp when this instance was created CreatedAt time.Time + // SessionID is the task session ID this instance was created for, if any. + // Used to correlate a recovered instance back to its owning session. + SessionID string + + // TaskID is the task ID this instance was created for, if any. + TaskID string + // manager is the process manager handling the agent subprocess (unexported) manager processManager @@ -64,6 +80,11 @@ type Instance struct { stopMu sync.Mutex statusMu sync.RWMutex portReleased bool + + // turnOutcome holds this instance's single retained last-terminal-turn + // slot (AC-EXECUTORS-SURVIVAL-004.1/.6). Zero value is ready to use -- + // no outcome retained. + turnOutcome turnOutcomeState } // MarkActivity stamps the current time as the most recent activity on this @@ -228,6 +249,26 @@ type InstanceInfo struct { // CreatedAt is the timestamp when this instance was created CreatedAt time.Time `json:"created_at"` + + // SessionID is the task session ID this instance was created for, if any. + SessionID string `json:"session_id,omitempty"` + + // TaskID is the task ID this instance was created for, if any. + TaskID string `json:"task_id,omitempty"` + + // WorkspaceSourceRoots is the live, current source-root allowlist this + // instance is enforcing right now -- a rescan or rebind can change it + // after creation, so this always reflects that, not a creation-time + // snapshot (AC-EXECUTORS-SURVIVAL-002.14's "workspace source roots" + // reconstruction row: read back, never pushed). + WorkspaceSourceRoots []string `json:"workspace_source_roots,omitempty"` + + // ProviderSessionID is the live agent CLI's own session identity, held by + // the adapter (AC-EXECUTORS-SURVIVAL-002.14's "provider session identity" + // reconstruction row: the adopted instance, which holds the provider + // session -- never the database). Distinct from SessionID above, which is + // this Kandev task session's identity, not the underlying agent's. + ProviderSessionID string `json:"provider_session_id,omitempty"` } // Info returns a safe copy of the instance data for API serialization. @@ -245,14 +286,25 @@ func (i *Instance) Info() *InstanceInfo { } } + var sourceRoots []string + var providerSessionID string + if i.manager != nil { + sourceRoots = i.manager.WorkspaceSourceRoots() + providerSessionID = i.manager.GetSessionID() + } + return &InstanceInfo{ - ID: i.ID, - Port: i.Port, - Status: i.Status, - WorkspacePath: i.WorkspacePath, - AgentCommand: i.AgentCommand, - Env: envCopy, - CreatedAt: i.CreatedAt, + ID: i.ID, + Port: i.Port, + Status: i.Status, + WorkspacePath: i.WorkspacePath, + AgentCommand: i.AgentCommand, + Env: envCopy, + CreatedAt: i.CreatedAt, + SessionID: i.SessionID, + TaskID: i.TaskID, + WorkspaceSourceRoots: sourceRoots, + ProviderSessionID: providerSessionID, } } diff --git a/apps/backend/internal/agentctl/server/instance/instance_info_test.go b/apps/backend/internal/agentctl/server/instance/instance_info_test.go new file mode 100644 index 0000000000..d11ffb48aa --- /dev/null +++ b/apps/backend/internal/agentctl/server/instance/instance_info_test.go @@ -0,0 +1,57 @@ +package instance + +import ( + "testing" + "time" +) + +// TestInstanceInfoReflectsLiveWorkspaceSourceRootsAndProviderSessionID pins +// AC-EXECUTORS-SURVIVAL-002.14's "workspace source roots" and "provider +// session identity" reconstruction rows: both are read back live from the +// process manager on every Info() call, not snapshotted at creation, so a +// rescan/rebind or a completed session/new is reflected immediately. +func TestInstanceInfoReflectsLiveWorkspaceSourceRootsAndProviderSessionID(t *testing.T) { + inst := &Instance{ + ID: "instance-1", + Port: 5001, + Status: "running", + CreatedAt: time.Now(), + manager: &fakeProcessManager{ + workspaceSourceRoots: []string{"/ws/task-1/backend", "/ws/task-1/frontend"}, + sessionID: "provider-session-9", + }, + } + + info := inst.Info() + + if len(info.WorkspaceSourceRoots) != 2 || + info.WorkspaceSourceRoots[0] != "/ws/task-1/backend" || + info.WorkspaceSourceRoots[1] != "/ws/task-1/frontend" { + t.Errorf("WorkspaceSourceRoots = %v, want the live manager allowlist", info.WorkspaceSourceRoots) + } + if info.ProviderSessionID != "provider-session-9" { + t.Errorf("ProviderSessionID = %q, want provider-session-9", info.ProviderSessionID) + } +} + +// TestInstanceInfoWithoutManagerLeavesRecoveryRowsEmpty pins the nil-manager +// guard: an Instance never wired to a process manager (defensive test-only +// shape) must not panic Info(), and both rows report empty rather than a +// stale or fabricated value. +func TestInstanceInfoWithoutManagerLeavesRecoveryRowsEmpty(t *testing.T) { + inst := &Instance{ + ID: "instance-1", + Port: 5001, + Status: "running", + CreatedAt: time.Now(), + } + + info := inst.Info() + + if info.WorkspaceSourceRoots != nil { + t.Errorf("WorkspaceSourceRoots = %v, want nil without a manager", info.WorkspaceSourceRoots) + } + if info.ProviderSessionID != "" { + t.Errorf("ProviderSessionID = %q, want empty without a manager", info.ProviderSessionID) + } +} diff --git a/apps/backend/internal/agentctl/server/instance/manager.go b/apps/backend/internal/agentctl/server/instance/manager.go index 7a1c033b78..07bb527c6d 100644 --- a/apps/backend/internal/agentctl/server/instance/manager.go +++ b/apps/backend/internal/agentctl/server/instance/manager.go @@ -10,6 +10,7 @@ import ( "os/exec" "strings" "sync" + "sync/atomic" "time" "github.com/google/uuid" @@ -71,6 +72,14 @@ type Manager struct { // racing cancellation against tracker startup and accepting whichever // outcome it happens to get. afterTrackerStart func() + + // turnIDSeq allocates the turn identifiers retained terminal outcomes + // are keyed by (AC-EXECUTORS-SURVIVAL-004.1). It is shared across every + // instance this Manager supervises and lives for this process's whole + // lifetime, matching the AC's "unique across every turn of every + // instance the control server supervises for as long as that control + // server runs" -- deliberately NOT reset per instance. + turnIDSeq atomic.Int64 } // NewManager creates a new instance manager. @@ -203,6 +212,13 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea // Create process manager procMgr := process.NewManager(instanceCfg, m.logger) + // Wire retained-outcome recording (AC-EXECUTORS-SURVIVAL-004) before + // anything that could reach Start(): this manager satisfies + // process.TurnOutcomeRecorder via RetainTurnOutcome above, and nothing + // outside this function can start the process manager until + // CreateInstance returns, so setting it here happens-before any + // terminal event the instance could ever produce. + procMgr.SetTurnOutcomeRecorder(id, m) // Materialize provider-qualified comparison targets before any tracker // polling starts. Failures remain explicit unavailable tracker state. procMgr.PrepareComparisonTargets(ctx) @@ -243,6 +259,8 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea AgentCommand: agentCmd, Env: req.Env, CreatedAt: time.Now(), + SessionID: req.SessionID, + TaskID: req.TaskID, manager: procMgr, } inst.MarkActivity() diff --git a/apps/backend/internal/agentctl/server/instance/manager_shutdown_test.go b/apps/backend/internal/agentctl/server/instance/manager_shutdown_test.go index 52059896ff..05f5f99715 100644 --- a/apps/backend/internal/agentctl/server/instance/manager_shutdown_test.go +++ b/apps/backend/internal/agentctl/server/instance/manager_shutdown_test.go @@ -314,11 +314,13 @@ func (s *fakeHTTPServer) Close() error { } type fakeProcessManager struct { - stopErr error - stopped bool - stopStarted chan<- struct{} - stopRelease <-chan struct{} - stopStartOnce sync.Once + stopErr error + stopped bool + stopStarted chan<- struct{} + stopRelease <-chan struct{} + stopStartOnce sync.Once + workspaceSourceRoots []string + sessionID string } // legacyResourceReleaseError proves a process teardown error cannot authorize @@ -335,6 +337,10 @@ func (e legacyResourceReleaseError) CanReleaseInstanceResources() bool { func (m *fakeProcessManager) CloseAdmission() {} +func (m *fakeProcessManager) WorkspaceSourceRoots() []string { return m.workspaceSourceRoots } + +func (m *fakeProcessManager) GetSessionID() string { return m.sessionID } + func (m *fakeProcessManager) StopForTeardown(context.Context) error { m.stopped = true if m.stopStarted != nil { diff --git a/apps/backend/internal/agentctl/server/instance/manager_turn_outcome.go b/apps/backend/internal/agentctl/server/instance/manager_turn_outcome.go new file mode 100644 index 0000000000..b986ff871d --- /dev/null +++ b/apps/backend/internal/agentctl/server/instance/manager_turn_outcome.go @@ -0,0 +1,56 @@ +package instance + +import "github.com/kandev/kandev/internal/agentctl/types/streams" + +// RetainTurnOutcome assigns a fresh turn identifier and records event as the +// named instance's new last terminal outcome (AC-EXECUTORS-SURVIVAL-004.1). +// Returns ok=false without allocating an identifier when the instance is not +// found, so a caller racing instance teardown never burns an identifier for +// an outcome nobody can retrieve. +func (m *Manager) RetainTurnOutcome(instanceID string, event streams.AgentEvent) (turnID int64, ok bool) { + inst, found := m.GetInstance(instanceID) + if !found { + return 0, false + } + turnID = m.turnIDSeq.Add(1) + inst.turnOutcome.Retain(turnID, event) + return turnID, true +} + +// PeekTurnOutcome returns the named instance's retained outcome without +// discarding it (AC-EXECUTORS-SURVIVAL-004.6). instanceFound distinguishes +// "no such instance" from "instance exists, nothing retained" so the HTTP +// handler can 404 only on the former. +func (m *Manager) PeekTurnOutcome(instanceID string) (outcome TurnOutcome, hasOutcome, instanceFound bool) { + inst, found := m.GetInstance(instanceID) + if !found { + return TurnOutcome{}, false, false + } + outcome, hasOutcome = inst.turnOutcome.Peek() + return outcome, hasOutcome, true +} + +// AckTurnOutcome discards the named instance's retained outcome only if +// turnID names the one currently retained. Naming any other identifier -- +// including one for an instance that no longer exists -- is accepted and +// changes nothing (AC-EXECUTORS-SURVIVAL-004.6), so a retried acknowledgement +// is always safe to call again. +func (m *Manager) AckTurnOutcome(instanceID string, turnID int64) { + inst, found := m.GetInstance(instanceID) + if !found { + return + } + inst.turnOutcome.Ack(turnID) +} + +// ClearTurnOutcome retires the prior terminal outcome before a new prompt is +// accepted. The prompt generation floor prevents a terminal event from an +// older turn, still waiting in the process manager's output path, from +// repopulating the slot after it was cleared. +func (m *Manager) ClearTurnOutcome(instanceID string, promptGeneration uint64) { + inst, found := m.GetInstance(instanceID) + if !found { + return + } + inst.turnOutcome.Clear(promptGeneration) +} diff --git a/apps/backend/internal/agentctl/server/instance/manager_turn_outcome_wiring_test.go b/apps/backend/internal/agentctl/server/instance/manager_turn_outcome_wiring_test.go new file mode 100644 index 0000000000..825d45a333 --- /dev/null +++ b/apps/backend/internal/agentctl/server/instance/manager_turn_outcome_wiring_test.go @@ -0,0 +1,70 @@ +package instance + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/agent" +) + +// TestCreateInstanceWiresTurnOutcomeRecorder pins the end-to-end wiring +// CreateInstance is responsible for (AC-EXECUTORS-SURVIVAL-004.1): a real +// terminal event sent through the process manager it constructs must reach +// this instance's retained-outcome slot, peekable via PeekTurnOutcome. Only +// exported process.Manager API is used (SendErrorEvent) so this exercises +// the production path -- forwardUpdates/sendUpdateBlocking calling back into +// recordTerminalOutcome -- rather than reaching into unexported internals. +func TestCreateInstanceWiresTurnOutcomeRecorder(t *testing.T) { + log := newTestLogger(t) + mgr := NewManager(&config.Config{ + Ports: config.PortConfig{Base: 0, Max: 0}, + Defaults: config.InstanceDefaults{Protocol: agent.ProtocolACP}, + }, log) + t.Cleanup(func() { _ = mgr.Shutdown(context.Background()) }) + + var captured *process.Manager + mgr.SetServerFactory(func(cfg *config.InstanceConfig, procMgr *process.Manager, log *logger.Logger) http.Handler { + captured = procMgr + return http.NotFoundHandler() + }) + + resp, err := mgr.CreateInstance(context.Background(), &CreateRequest{WorkspacePath: t.TempDir()}) + if err != nil { + t.Fatalf("CreateInstance: %v", err) + } + t.Cleanup(func() { _ = mgr.StopInstance(context.Background(), resp.ID) }) + + if captured == nil { + t.Fatal("server factory was never invoked; no process.Manager captured") + } + + if _, hasOutcome, found := mgr.PeekTurnOutcome(resp.ID); !found || hasOutcome { + t.Fatalf("hasOutcome = %v, found = %v before any terminal event, want found=true hasOutcome=false", hasOutcome, found) + } + + captured.SendErrorEvent("boom", 1) + + deadline := time.After(time.Second) + for { + outcome, hasOutcome, found := mgr.PeekTurnOutcome(resp.ID) + if !found { + t.Fatal("instance not found while polling for the retained outcome") + } + if hasOutcome { + if outcome.Event.Error != "boom" { + t.Fatalf("retained outcome error = %q, want boom", outcome.Event.Error) + } + return + } + select { + case <-deadline: + t.Fatal("timed out waiting for SendErrorEvent to reach the retained outcome slot") + case <-time.After(time.Millisecond): + } + } +} diff --git a/apps/backend/internal/agentctl/server/instance/turn_outcome.go b/apps/backend/internal/agentctl/server/instance/turn_outcome.go new file mode 100644 index 0000000000..16b01c78ab --- /dev/null +++ b/apps/backend/internal/agentctl/server/instance/turn_outcome.go @@ -0,0 +1,81 @@ +package instance + +import ( + "sync" + + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// TurnOutcome pairs a terminal agent event with the identifier assigned to +// the turn it belongs to (AC-EXECUTORS-SURVIVAL-004.1). The identifier is +// scoped to this control server's own process lifetime -- it is not the +// durable, cross-restart Kandev turn identity the rest of the backend +// already tracks (streams.AgentEvent.TurnID). Reusing that value would tie +// this retention mechanism to identifiers that already predate any given +// control-server launch, when the AC explicitly only requires uniqueness +// "for as long as that control server runs". +type TurnOutcome struct { + TurnID int64 `json:"turn_id"` + Event streams.AgentEvent `json:"event"` +} + +// turnOutcomeState holds the single retained outcome slot for one instance. +// Only the LAST terminal outcome is kept (design 03: "agentctl therefore +// retains each instance's last terminal turn outcome") -- retaining a +// history is unnecessary because a session can only be in one terminal +// state at re-track time, and design 03 explicitly leaves applying that one +// state as the whole job of AC-EXECUTORS-SURVIVAL-004.2. +type turnOutcomeState struct { + mu sync.Mutex + retained *TurnOutcome + minimumPromptGeneration uint64 +} + +// Retain records event as the instance's new last terminal outcome under +// the given turnID, replacing whatever was retained before. Retrieval never +// clears this state (AC-EXECUTORS-SURVIVAL-004.6) -- only Ack does. +func (s *turnOutcomeState) Retain(turnID int64, event streams.AgentEvent) { + s.mu.Lock() + defer s.mu.Unlock() + if event.PromptGeneration != 0 && event.PromptGeneration < s.minimumPromptGeneration { + return + } + s.retained = &TurnOutcome{TurnID: turnID, Event: event} +} + +// Peek returns the retained outcome, if any, without discarding it. Calling +// it repeatedly returns the same outcome and turn identifier until Ack +// removes it (AC-EXECUTORS-SURVIVAL-004.6's repeatable, non-discarding read). +func (s *turnOutcomeState) Peek() (TurnOutcome, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.retained == nil { + return TurnOutcome{}, false + } + return *s.retained, true +} + +// Ack discards the retained outcome only when turnID names the one +// currently retained. Naming an identifier that is not (or no longer) +// retained is accepted and changes nothing, so a retried acknowledgement -- +// or one that arrives after a newer outcome has already replaced it -- is +// always safe (AC-EXECUTORS-SURVIVAL-004.6). +func (s *turnOutcomeState) Ack(turnID int64) { + s.mu.Lock() + defer s.mu.Unlock() + if s.retained != nil && s.retained.TurnID == turnID { + s.retained = nil + } +} + +// Clear retires the current outcome and records the generation of the prompt +// that follows it. Terminal events from older generations are ignored when +// they arrive late through the process manager's output path. +func (s *turnOutcomeState) Clear(promptGeneration uint64) { + s.mu.Lock() + defer s.mu.Unlock() + s.retained = nil + if promptGeneration > s.minimumPromptGeneration { + s.minimumPromptGeneration = promptGeneration + } +} diff --git a/apps/backend/internal/agentctl/server/instance/turn_outcome_test.go b/apps/backend/internal/agentctl/server/instance/turn_outcome_test.go new file mode 100644 index 0000000000..dfe1911dca --- /dev/null +++ b/apps/backend/internal/agentctl/server/instance/turn_outcome_test.go @@ -0,0 +1,199 @@ +package instance + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/pkg/agent" +) + +func newTurnOutcomeTestManager(t *testing.T) *Manager { + t.Helper() + log := newTestLogger(t) + cfg := &config.Config{ + Ports: config.PortConfig{Base: 0, Max: 0}, + Defaults: config.InstanceDefaults{Protocol: agent.ProtocolACP}, + } + mgr := NewManager(cfg, log) + t.Cleanup(func() { _ = mgr.Shutdown(context.Background()) }) + return mgr +} + +func addTestInstance(t *testing.T, mgr *Manager, id string) *Instance { + t.Helper() + inst := &Instance{ID: id} + mgr.mu.Lock() + mgr.instances[id] = inst + mgr.mu.Unlock() + return inst +} + +// TestRetainTurnOutcomeReturnsFalseForUnknownInstance pins that no turn +// identifier is allocated for an instance that does not exist -- a caller +// racing instance teardown should never burn an identifier nobody can +// retrieve. +func TestRetainTurnOutcomeReturnsFalseForUnknownInstance(t *testing.T) { + mgr := newTurnOutcomeTestManager(t) + + turnID, ok := mgr.RetainTurnOutcome("missing", streams.AgentEvent{Type: streams.EventTypeComplete}) + if ok { + t.Fatal("RetainTurnOutcome() ok = true for an unknown instance, want false") + } + if turnID != 0 { + t.Fatalf("RetainTurnOutcome() turnID = %d for an unknown instance, want 0", turnID) + } +} + +// TestPeekTurnOutcomeIsRepeatableAndNonDiscarding pins +// AC-EXECUTORS-SURVIVAL-004.6: retrieval never clears the retained outcome, +// so repeated reads return the identical outcome and turn identifier. +func TestPeekTurnOutcomeIsRepeatableAndNonDiscarding(t *testing.T) { + mgr := newTurnOutcomeTestManager(t) + addTestInstance(t, mgr, "inst-1") + + turnID, ok := mgr.RetainTurnOutcome("inst-1", streams.AgentEvent{Type: streams.EventTypeComplete, SessionID: "sess-1"}) + if !ok { + t.Fatal("RetainTurnOutcome() ok = false, want true") + } + + for i := 0; i < 3; i++ { + outcome, hasOutcome, instanceFound := mgr.PeekTurnOutcome("inst-1") + if !instanceFound { + t.Fatalf("iteration %d: instanceFound = false, want true", i) + } + if !hasOutcome { + t.Fatalf("iteration %d: hasOutcome = false, want true (retrieval must not discard)", i) + } + if outcome.TurnID != turnID { + t.Fatalf("iteration %d: TurnID = %d, want %d", i, outcome.TurnID, turnID) + } + if outcome.Event.SessionID != "sess-1" { + t.Fatalf("iteration %d: Event.SessionID = %q, want %q", i, outcome.Event.SessionID, "sess-1") + } + } +} + +// TestPeekTurnOutcomeDistinguishesUnknownInstanceFromNothingRetained pins +// that a 404-worthy "no such instance" is distinguishable from the normal +// "instance exists, nothing retained yet" answer. +func TestPeekTurnOutcomeDistinguishesUnknownInstanceFromNothingRetained(t *testing.T) { + mgr := newTurnOutcomeTestManager(t) + addTestInstance(t, mgr, "inst-1") + + _, hasOutcome, instanceFound := mgr.PeekTurnOutcome("inst-1") + if !instanceFound { + t.Fatal("instanceFound = false for a real instance, want true") + } + if hasOutcome { + t.Fatal("hasOutcome = true before anything was retained, want false") + } + + _, _, instanceFound = mgr.PeekTurnOutcome("missing") + if instanceFound { + t.Fatal("instanceFound = true for an unknown instance, want false") + } +} + +// TestAckTurnOutcomeDiscardsOnlyTheMatchingIdentifier pins the core of +// AC-EXECUTORS-SURVIVAL-004.6: acking the wrong identifier changes nothing, +// while acking the right one discards it. +func TestAckTurnOutcomeDiscardsOnlyTheMatchingIdentifier(t *testing.T) { + mgr := newTurnOutcomeTestManager(t) + addTestInstance(t, mgr, "inst-1") + + turnID, _ := mgr.RetainTurnOutcome("inst-1", streams.AgentEvent{Type: streams.EventTypeComplete}) + + mgr.AckTurnOutcome("inst-1", turnID+999) + if _, hasOutcome, _ := mgr.PeekTurnOutcome("inst-1"); !hasOutcome { + t.Fatal("acking the wrong turn ID discarded the outcome, want it left retained") + } + + mgr.AckTurnOutcome("inst-1", turnID) + if _, hasOutcome, _ := mgr.PeekTurnOutcome("inst-1"); hasOutcome { + t.Fatal("acking the matching turn ID left the outcome retained, want it discarded") + } +} + +// TestAckTurnOutcomeOnUnknownInstanceIsANoOp pins that acking an instance +// that no longer exists (e.g. already torn down) never panics and never +// errors -- an unknown identifier is always accepted and does nothing. +func TestAckTurnOutcomeOnUnknownInstanceIsANoOp(t *testing.T) { + mgr := newTurnOutcomeTestManager(t) + mgr.AckTurnOutcome("missing", 1) +} + +func TestClearTurnOutcomeRetiresPriorOutcomeAndRejectsOlderLateEvents(t *testing.T) { + mgr := newTurnOutcomeTestManager(t) + addTestInstance(t, mgr, "inst-1") + firstID, _ := mgr.RetainTurnOutcome("inst-1", streams.AgentEvent{ + Type: streams.EventTypeComplete, + PromptGeneration: 1, + }) + if firstID == 0 { + t.Fatal("first retained outcome did not receive an identifier") + } + + mgr.ClearTurnOutcome("inst-1", 2) + if _, hasOutcome, _ := mgr.PeekTurnOutcome("inst-1"); hasOutcome { + t.Fatal("ClearTurnOutcome left the prior terminal outcome retained") + } + + mgr.RetainTurnOutcome("inst-1", streams.AgentEvent{ + Type: streams.EventTypeError, + PromptGeneration: 1, + }) + if _, hasOutcome, _ := mgr.PeekTurnOutcome("inst-1"); hasOutcome { + t.Fatal("late terminal event from the prior generation repopulated the slot") + } + + mgr.RetainTurnOutcome("inst-1", streams.AgentEvent{ + Type: streams.EventTypeComplete, + PromptGeneration: 2, + }) + outcome, hasOutcome, _ := mgr.PeekTurnOutcome("inst-1") + if !hasOutcome || outcome.Event.PromptGeneration != 2 { + t.Fatalf("current-generation outcome = %+v, retained = %v; want generation 2", outcome, hasOutcome) + } +} + +// TestRetainTurnOutcomeAllocatesStrictlyIncreasingIdentifiersAcrossInstances +// pins AC-EXECUTORS-SURVIVAL-004.1: the identifier is unique across every +// turn of every instance this control server supervises, not just within +// one instance. +func TestRetainTurnOutcomeAllocatesStrictlyIncreasingIdentifiersAcrossInstances(t *testing.T) { + mgr := newTurnOutcomeTestManager(t) + addTestInstance(t, mgr, "inst-1") + addTestInstance(t, mgr, "inst-2") + + id1, _ := mgr.RetainTurnOutcome("inst-1", streams.AgentEvent{Type: streams.EventTypeComplete}) + id2, _ := mgr.RetainTurnOutcome("inst-2", streams.AgentEvent{Type: streams.EventTypeComplete}) + id3, _ := mgr.RetainTurnOutcome("inst-1", streams.AgentEvent{Type: streams.EventTypeError}) + + if id1 == id2 || id2 == id3 || id1 == id3 { + t.Fatalf("turn identifiers were not unique: id1=%d id2=%d id3=%d", id1, id2, id3) + } +} + +// TestRetainTurnOutcomeReplacesThePreviouslyRetainedOutcome pins design 03's +// "agentctl therefore retains each instance's last terminal turn outcome" -- +// only the most recent terminal outcome is kept, not a history. +func TestRetainTurnOutcomeReplacesThePreviouslyRetainedOutcome(t *testing.T) { + mgr := newTurnOutcomeTestManager(t) + addTestInstance(t, mgr, "inst-1") + + firstID, _ := mgr.RetainTurnOutcome("inst-1", streams.AgentEvent{Type: streams.EventTypeError}) + secondID, _ := mgr.RetainTurnOutcome("inst-1", streams.AgentEvent{Type: streams.EventTypeComplete}) + + outcome, hasOutcome, _ := mgr.PeekTurnOutcome("inst-1") + if !hasOutcome { + t.Fatal("hasOutcome = false, want true") + } + if outcome.TurnID != secondID { + t.Fatalf("TurnID = %d, want the second (most recent) outcome's ID %d (first was %d)", outcome.TurnID, secondID, firstID) + } + if outcome.Event.Type != streams.EventTypeComplete { + t.Fatalf("Event.Type = %q, want the most recent outcome's type %q", outcome.Event.Type, streams.EventTypeComplete) + } +} diff --git a/apps/backend/internal/agentctl/server/process/attachment.go b/apps/backend/internal/agentctl/server/process/attachment.go new file mode 100644 index 0000000000..5ae8df4ba6 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/attachment.go @@ -0,0 +1,34 @@ +package process + +// attachedCount tracks how many live backend event-stream connections +// (/agent/stream) this instance currently has. It is a count rather than a +// single flag so an overlapping reconnect -- a new connection arriving +// before the old one's teardown has run -- cannot make IsAttached report +// false while a connection is still actually live. +// +// This is the agentctl-local "instance is attached" signal design 03 +// requires: an instance is attached when its event stream to the owning +// backend is established, evaluated at INSTANCE granularity (not "the +// server has an owning backend", which is true during the window after +// adoption and before this instance's own stream reconnects -- exactly the +// window a permission request must not be auto-denied in). + +// MarkAttached records that a backend event-stream connection is now live +// for this instance. Called by the /agent/stream handler once the +// WebSocket upgrade succeeds. +func (m *Manager) MarkAttached() { + m.attachedCount.Add(1) +} + +// MarkDetached records that a backend event-stream connection for this +// instance has ended. Called by the /agent/stream handler when the +// connection closes, for symmetry with MarkAttached. +func (m *Manager) MarkDetached() { + m.attachedCount.Add(-1) +} + +// IsAttached reports whether this instance currently has at least one live +// backend event-stream connection. +func (m *Manager) IsAttached() bool { + return m.attachedCount.Load() > 0 +} diff --git a/apps/backend/internal/agentctl/server/process/attachment_test.go b/apps/backend/internal/agentctl/server/process/attachment_test.go new file mode 100644 index 0000000000..218127cdf2 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/attachment_test.go @@ -0,0 +1,51 @@ +package process + +import "testing" + +// TestManagerStartsDetached pins that a freshly constructed Manager has no +// attached backend event stream yet -- matching design 03's "the coarser +// [server-owned] granularity would auto-deny exactly [in the window] before +// this instance's streams are up" caveat: a permission request in that +// window must be treated as detached, not attached. +func TestManagerStartsDetached(t *testing.T) { + m := &Manager{} + if m.IsAttached() { + t.Fatal("IsAttached() = true for a freshly constructed Manager, want false") + } +} + +// TestMarkAttachedAndMarkDetachedToggleIsAttached pins the basic connect/ +// disconnect transition the WS stream handler drives. +func TestMarkAttachedAndMarkDetachedToggleIsAttached(t *testing.T) { + m := &Manager{} + m.MarkAttached() + if !m.IsAttached() { + t.Fatal("IsAttached() = false after MarkAttached, want true") + } + m.MarkDetached() + if m.IsAttached() { + t.Fatal("IsAttached() = true after MarkDetached, want false") + } +} + +// TestIsAttachedStaysTrueAcrossAnOverlappingReconnect pins that a second, +// overlapping stream connection (e.g. a reconnect racing the old +// connection's teardown) does not make the instance look detached the +// moment the FIRST connection's defer runs -- attachment is a count, not a +// single flag, so it only reaches zero once every live connection has +// disconnected. +func TestIsAttachedStaysTrueAcrossAnOverlappingReconnect(t *testing.T) { + m := &Manager{} + m.MarkAttached() // first connection + m.MarkAttached() // second, overlapping connection + + m.MarkDetached() // first connection's defer fires + if !m.IsAttached() { + t.Fatal("IsAttached() = false with one connection still live, want true") + } + + m.MarkDetached() // second connection's defer fires + if m.IsAttached() { + t.Fatal("IsAttached() = true after every connection disconnected, want false") + } +} diff --git a/apps/backend/internal/agentctl/server/process/blocking_send.go b/apps/backend/internal/agentctl/server/process/blocking_send.go new file mode 100644 index 0000000000..d754728419 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/blocking_send.go @@ -0,0 +1,40 @@ +package process + +import "github.com/kandev/kandev/internal/agentctl/server/adapter" + +// sendUpdateBlocking sends event on the updates channel, parking the caller +// while the channel is full rather than discarding the event +// (AC-EXECUTORS-SURVIVAL-001.5/.6). The park selects against the instance's +// current stop signal, mirroring forwardUpdates's own send: stopping the +// instance releases every producer parked on it immediately, without +// waiting for a backend that may never attach. Returns false when the +// instance stopped (or never started) before the event could be delivered. +// +// The fast path never depends on a stop channel: when there is room, the +// send just happens, matching the un-parked behaviour every one of these +// call sites had before conversion. Only a send that would actually block +// needs a stop channel to select against; one that was never established +// (Start has not run) is treated as already stopped rather than parking +// forever with no lifecycle that could ever release it. +func (m *Manager) sendUpdateBlocking(event adapter.AgentEvent) bool { + m.recordTerminalOutcome(&event) + + select { + case m.updatesCh <- event: + return true + default: + } + + stopChVal := m.stopChSnapshot.Load() + if stopChVal == nil { + return false + } + stopCh := stopChVal.(chan struct{}) + + select { + case m.updatesCh <- event: + return true + case <-stopCh: + return false + } +} diff --git a/apps/backend/internal/agentctl/server/process/blocking_send_sites_test.go b/apps/backend/internal/agentctl/server/process/blocking_send_sites_test.go new file mode 100644 index 0000000000..c04e4f2745 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/blocking_send_sites_test.go @@ -0,0 +1,324 @@ +package process + +import ( + "context" + "os" + "os/exec" + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" +) + +// parkThenAssertRelease is the shared shape for the three "must park, not +// discard" site tests below: fill updatesCh, run the site's send in a +// goroutine, confirm it has not returned while the channel stays full, then +// release it (by draining the channel or closing stopCh) and confirm the +// goroutine finishes promptly afterward. +func parkThenAssertRelease(t *testing.T, run func(), release func()) { + t.Helper() + done := make(chan struct{}) + go func() { + run() + close(done) + }() + + select { + case <-done: + t.Fatal("send returned before the channel had room or a stop was signalled -- it must park, not discard") + case <-time.After(50 * time.Millisecond): + } + + release() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("send did not return promptly after release") + } +} + +// TestSendErrorEventWithProviderErrorParksOnAFullChannel pins +// AC-EXECUTORS-SURVIVAL-001.5/.6 for the agent-ERROR site: a full channel +// must park the caller rather than silently drop the error, and must +// release once a consumer drains a slot. +func TestSendErrorEventWithProviderErrorParksOnAFullChannel(t *testing.T) { + m := &Manager{ + updatesCh: make(chan adapter.AgentEvent, 1), + logger: newTestLogger(t), + } + m.stopChSnapshot.Store(make(chan struct{})) + m.updatesCh <- adapter.AgentEvent{Type: adapter.EventTypeComplete} // fill the buffer + + parkThenAssertRelease(t, + func() { m.SendErrorEventWithProviderError("boom", 1, nil) }, + func() { <-m.updatesCh }, + ) + + select { + case event := <-m.updatesCh: + if event.Error != "boom" { + t.Fatalf("Error = %q, want %q", event.Error, "boom") + } + default: + t.Fatal("the parked error event was never delivered") + } +} + +// TestSendErrorEventWithProviderErrorReleasesOnStop pins the pause-not-a-hang +// half: a parked send must release the instant stop fires, without ever +// delivering the event. +func TestSendErrorEventWithProviderErrorReleasesOnStop(t *testing.T) { + m := &Manager{ + updatesCh: make(chan adapter.AgentEvent, 1), + logger: newTestLogger(t), + } + stopCh := make(chan struct{}) + m.stopChSnapshot.Store(stopCh) + m.updatesCh <- adapter.AgentEvent{Type: adapter.EventTypeComplete} + + parkThenAssertRelease(t, + func() { m.SendErrorEventWithProviderError("boom", 1, nil) }, + func() { close(stopCh) }, + ) +} + +// TestSendErrorEventWithProviderErrorReleasesOnRealManagerStop pins the same +// release guarantee driven through the real Manager.Stop path, not a +// hand-closed stopCh standing in for it: a full channel must still park a +// send in flight when Stop is called on a started manager, and Stop's own +// teardown (closeAdapterAndStdin closing stopCh) must release it and return +// within a bounded time, not hang waiting on the parked goroutine. +func TestSendErrorEventWithProviderErrorReleasesOnRealManagerStop(t *testing.T) { + m := &Manager{ + updatesCh: make(chan adapter.AgentEvent, 1), + logger: newTestLogger(t), + } + m.stopCh = make(chan struct{}) + m.stopChSnapshot.Store(m.stopCh) + m.status.Store(StatusRunning) + m.updatesCh <- adapter.AgentEvent{Type: adapter.EventTypeComplete} + + parkThenAssertRelease(t, + func() { m.SendErrorEventWithProviderError("boom", 1, nil) }, + func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := m.Stop(ctx); err != nil { + t.Errorf("Stop: %v", err) + } + }, + ) +} + +// TestSendPermissionCancelledNotificationParksOnAFullChannel pins +// AC-EXECUTORS-SURVIVAL-001.5/.6 for the permission-cancelled site. +func TestSendPermissionCancelledNotificationParksOnAFullChannel(t *testing.T) { + m := &Manager{ + updatesCh: make(chan adapter.AgentEvent, 1), + logger: newTestLogger(t), + } + m.stopChSnapshot.Store(make(chan struct{})) + m.updatesCh <- adapter.AgentEvent{Type: adapter.EventTypeComplete} + pending := &PendingPermission{ID: "pending-1", RequestID: "req-1", Request: &adapter.PermissionRequest{}} + + parkThenAssertRelease(t, + func() { m.sendPermissionCancelledNotification(pending) }, + func() { <-m.updatesCh }, + ) + + select { + case event := <-m.updatesCh: + if event.Type != adapter.EventTypePermissionCancelled || event.PendingID != "pending-1" { + t.Fatalf("event = %+v, want a permission-cancelled event for pending-1", event) + } + default: + t.Fatal("the parked permission-cancelled event was never delivered") + } +} + +// newPendingPermissionForNotificationTest builds a PendingPermission with a +// populated Snapshot, matching the shape sendPermissionNotification reads. +func newPendingPermissionForNotificationTest(m *Manager) *PendingPermission { + pending := &PendingPermission{ + ID: "pending-1", + RequestID: "req-1", + Request: &adapter.PermissionRequest{ + SessionID: "session-1", + ToolCallID: "tool-1", + Title: "Run command", + Options: []adapter.PermissionOption{ + {OptionID: "allow-once", Kind: "allow_once"}, + }, + }, + ResponseCh: make(chan *adapter.PermissionResponse, 1), + } + pending.Snapshot = m.permissionSnapshot(pending) + return pending +} + +// TestSendPermissionNotificationParksWhenDetachedOnAFullChannel pins the +// behaviour change design 03 requires for the eighth COVERED site: while +// DETACHED, a full channel must park the permission-request notification +// rather than auto-cancelling after five seconds -- auto-cancelling while +// detached would silently deny every permission the agent asks for. +func TestSendPermissionNotificationParksWhenDetachedOnAFullChannel(t *testing.T) { + m := &Manager{ + updatesCh: make(chan adapter.AgentEvent, 1), + logger: newTestLogger(t), + } + m.stopChSnapshot.Store(make(chan struct{})) + m.updatesCh <- adapter.AgentEvent{Type: adapter.EventTypeComplete} // fill the buffer + pending := newPendingPermissionForNotificationTest(m) + + parkThenAssertRelease(t, + func() { m.sendPermissionNotification(pending) }, + func() { <-m.updatesCh }, + ) + + select { + case event := <-m.updatesCh: + if event.Type != adapter.EventTypePermissionRequest || event.PendingID != "pending-1" { + t.Fatalf("event = %+v, want a permission-request event for pending-1", event) + } + default: + t.Fatal("the parked permission-request event was never delivered") + } + select { + case resp := <-pending.ResponseCh: + t.Fatalf("ResponseCh received %+v, want no auto-cancel while parked", resp) + default: + } +} + +// TestSendPermissionNotificationReleasesOnStopWithoutAutoCancellingWhenDetached +// pins the "pause not a hang" half for the detached branch: a parked +// permission-request notification releases on instance stop like every +// other COVERED site, and -- unlike the attached five-second timer path -- +// does not synthesize a Cancelled response, since nobody is waiting to be +// denied and the instance is being torn down anyway. +func TestSendPermissionNotificationReleasesOnStopWithoutAutoCancellingWhenDetached(t *testing.T) { + m := &Manager{ + updatesCh: make(chan adapter.AgentEvent, 1), + logger: newTestLogger(t), + } + stopCh := make(chan struct{}) + m.stopChSnapshot.Store(stopCh) + m.updatesCh <- adapter.AgentEvent{Type: adapter.EventTypeComplete} + pending := newPendingPermissionForNotificationTest(m) + + parkThenAssertRelease(t, + func() { m.sendPermissionNotification(pending) }, + func() { close(stopCh) }, + ) + + select { + case resp := <-pending.ResponseCh: + t.Fatalf("ResponseCh received %+v, want no auto-cancel on a stop release", resp) + default: + } +} + +// TestSendPermissionNotificationSendsImmediatelyWhenAttachedAndRoomAvailable +// pins that the attached branch is unchanged for the common case: with room +// on the channel, the event is delivered the same way it always was, +// regardless of the new attached/detached gate. +func TestSendPermissionNotificationSendsImmediatelyWhenAttachedAndRoomAvailable(t *testing.T) { + m := &Manager{ + updatesCh: make(chan adapter.AgentEvent, 1), + logger: newTestLogger(t), + } + m.MarkAttached() + pending := newPendingPermissionForNotificationTest(m) + + m.sendPermissionNotification(pending) + + select { + case event := <-m.updatesCh: + if event.Type != adapter.EventTypePermissionRequest || event.PendingID != "pending-1" { + t.Fatalf("event = %+v, want a permission-request event for pending-1", event) + } + default: + t.Fatal("attached send with room available did not deliver the event") + } +} + +// TestManagerFailingProcessHelper is a self-exec subprocess helper that +// exits with a nonzero status, used by +// TestWaitForExitParksTheExitErrorEventOnAFullChannel below to drive +// waitForExit's real error branch through a real process exit rather than a +// fabricated exec.Cmd state. +func TestManagerFailingProcessHelper(t *testing.T) { + if os.Getenv("KANDEV_MANAGER_FAILING_PROCESS_HELPER") != "1" { + return + } + os.Exit(3) +} + +// TestWaitForExitParksTheExitErrorEventOnAFullChannel pins +// AC-EXECUTORS-SURVIVAL-001.5/.6 for the agent-process-exit-error site: the +// exit error event carrying exit code and recent stderr must park on a full +// channel rather than being dropped, since it is the only report that the +// agent process died at all. +func TestWaitForExitParksTheExitErrorEventOnAFullChannel(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=TestManagerFailingProcessHelper") + cmd.Env = append(os.Environ(), "KANDEV_MANAGER_FAILING_PROCESS_HELPER=1") + stderr, stderrWriter, err := os.Pipe() + if err != nil { + t.Fatalf("stderr pipe: %v", err) + } + cmd.Stderr = stderrWriter + m := &Manager{ + cmd: cmd, + stderr: stderr, + logger: newTestLogger(t), + doneCh: make(chan struct{}), + updatesCh: make(chan adapter.AgentEvent, 1), + groupAliveFn: func(int) bool { return false }, + } + m.status.Store(StatusRunning) + // sendUpdateBlocking treats an unestablished stopChSnapshot as "already + // stopped" (see its doc comment) so a genuinely never-started Manager + // doesn't park forever. Since this test builds a Manager by struct + // literal instead of through Start(), it must seed the snapshot itself + // the same way Start() does, or the send below would return immediately + // on a fast cmd.Wait() instead of parking -- a real flake this test hit + // before the snapshot was added here. + m.stopChSnapshot.Store(make(chan struct{})) + m.updatesCh <- adapter.AgentEvent{Type: adapter.EventTypeComplete} // fill the buffer + t.Cleanup(func() { + if cmd.ProcessState == nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = stderrWriter.Close() + _ = stderr.Close() + m.wg.Wait() + }) + if err := cmd.Start(); err != nil { + t.Fatalf("start process: %v", err) + } + if err := stderrWriter.Close(); err != nil { + t.Fatalf("close parent stderr pipe: %v", err) + } + + stderrDone := make(chan struct{}) + m.wg.Add(2) + go m.readStderr(stderrDone) + + parkThenAssertRelease(t, + func() { m.waitForExit(stderrDone) }, + func() { <-m.updatesCh }, + ) + + select { + case event := <-m.updatesCh: + if event.Type != adapter.EventTypeError { + t.Fatalf("event type = %q, want error", event.Type) + } + if data, ok := event.Data["exit_code"]; !ok || data != 3 { + t.Fatalf("exit_code = %v, want 3", data) + } + default: + t.Fatal("the parked exit-error event was never delivered") + } +} diff --git a/apps/backend/internal/agentctl/server/process/blocking_send_test.go b/apps/backend/internal/agentctl/server/process/blocking_send_test.go new file mode 100644 index 0000000000..769978b1aa --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/blocking_send_test.go @@ -0,0 +1,154 @@ +package process + +import ( + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" +) + +// TestSendUpdateBlockingSendsImmediatelyWhenChannelHasRoom pins the common +// case: a converted send behaves exactly like the non-blocking one did +// whenever the channel isn't actually full. +func TestSendUpdateBlockingSendsImmediatelyWhenChannelHasRoom(t *testing.T) { + m := &Manager{ + logger: newTestLogger(t), + updatesCh: make(chan adapter.AgentEvent, 1), + } + stopCh := make(chan struct{}) + m.stopChSnapshot.Store(stopCh) + + if ok := m.sendUpdateBlocking(adapter.AgentEvent{Type: adapter.EventTypeError, Error: "boom"}); !ok { + t.Fatal("sendUpdateBlocking() = false, want true (channel had room)") + } + + select { + case event := <-m.updatesCh: + if event.Error != "boom" { + t.Fatalf("Error = %q, want %q", event.Error, "boom") + } + default: + t.Fatal("event was not enqueued on updatesCh") + } +} + +// TestSendUpdateBlockingParksUntilRoomFreesUp pins AC-EXECUTORS-SURVIVAL-001.5/.6: +// a full channel must PARK the producer rather than discard the event, and +// deliver it once a consumer drains the channel. +func TestSendUpdateBlockingParksUntilRoomFreesUp(t *testing.T) { + m := &Manager{ + logger: newTestLogger(t), + updatesCh: make(chan adapter.AgentEvent, 1), + } + stopCh := make(chan struct{}) + m.stopChSnapshot.Store(stopCh) + m.updatesCh <- adapter.AgentEvent{Type: adapter.EventTypeComplete} // fill the buffer + + done := make(chan bool, 1) + go func() { + done <- m.sendUpdateBlocking(adapter.AgentEvent{Type: adapter.EventTypeError, Error: "parked"}) + }() + + select { + case <-done: + t.Fatal("sendUpdateBlocking returned before the channel had room -- it must park, not discard") + case <-time.After(50 * time.Millisecond): + } + + <-m.updatesCh // drain the blocking event, freeing a slot + + select { + case ok := <-done: + if !ok { + t.Fatal("sendUpdateBlocking() = false once room freed up, want true") + } + case <-time.After(time.Second): + t.Fatal("sendUpdateBlocking did not unblock after the channel drained") + } + + select { + case event := <-m.updatesCh: + if event.Error != "parked" { + t.Fatalf("Error = %q, want %q", event.Error, "parked") + } + default: + t.Fatal("the parked event was never delivered") + } +} + +// TestSendUpdateBlockingReleasesOnStop pins the "pause not a hang" half of +// the design: a producer parked on a full channel must be released the +// instant the instance's stop signal fires, without waiting for a consumer +// that may never arrive. +func TestSendUpdateBlockingReleasesOnStop(t *testing.T) { + m := &Manager{ + logger: newTestLogger(t), + updatesCh: make(chan adapter.AgentEvent, 1), + } + stopCh := make(chan struct{}) + m.stopChSnapshot.Store(stopCh) + m.updatesCh <- adapter.AgentEvent{Type: adapter.EventTypeComplete} // fill the buffer + + done := make(chan bool, 1) + go func() { + done <- m.sendUpdateBlocking(adapter.AgentEvent{Type: adapter.EventTypeError, Error: "released"}) + }() + + select { + case <-done: + t.Fatal("sendUpdateBlocking returned before stop was signalled") + case <-time.After(50 * time.Millisecond): + } + + close(stopCh) + + select { + case ok := <-done: + if ok { + t.Fatal("sendUpdateBlocking() = true after a stop release, want false (event must not be delivered)") + } + case <-time.After(time.Second): + t.Fatal("sendUpdateBlocking did not release on stop") + } +} + +// TestSendUpdateBlockingSendsWithRoomEvenWithNoStopChannelEstablished pins +// that the fast, common path never depends on a stop channel existing at +// all: when there's room, the send just happens. +func TestSendUpdateBlockingSendsWithRoomEvenWithNoStopChannelEstablished(t *testing.T) { + m := &Manager{ + logger: newTestLogger(t), + updatesCh: make(chan adapter.AgentEvent, 1), + } + + if ok := m.sendUpdateBlocking(adapter.AgentEvent{Type: adapter.EventTypeError}); !ok { + t.Fatal("sendUpdateBlocking() = false with room available, want true regardless of stop-channel state") + } +} + +// TestSendUpdateBlockingReturnsFalseImmediatelyWhenNeverStartedAndFull pins +// the defensive behaviour for a Manager whose stop channel was never +// established (Start has not run) and whose channel is actually full: treat +// it as already stopped rather than parking forever with no lifecycle that +// could ever release it. +func TestSendUpdateBlockingReturnsFalseImmediatelyWhenNeverStartedAndFull(t *testing.T) { + m := &Manager{ + logger: newTestLogger(t), + updatesCh: make(chan adapter.AgentEvent, 1), + } + m.updatesCh <- adapter.AgentEvent{Type: adapter.EventTypeComplete} // fill the buffer + + done := make(chan bool, 1) + go func() { + done <- m.sendUpdateBlocking(adapter.AgentEvent{Type: adapter.EventTypeError}) + }() + + select { + case ok := <-done: + if ok { + t.Fatal("sendUpdateBlocking() = true with no stop channel established, want false") + } + case <-time.After(time.Second): + t.Fatal("sendUpdateBlocking did not return promptly with no stop channel established") + } +} diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index ad5786a2ec..3e4be1519a 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -45,6 +45,14 @@ const ( StatusError Status = "error" ) +// defaultUpdatesChannelCapacity is the updates channel buffer size used when +// InstanceConfig.DetachedEventLimit is left at its Go zero value -- the +// common case for tests and any other caller that builds a config.InstanceConfig +// directly instead of going through Config.NewInstanceConfig. It matches the +// agentctl.detachedEventLimit catalog tunable's own default +// (AC-EXECUTORS-SURVIVAL-001.6). +const defaultUpdatesChannelCapacity = 100 + // errorWrapper wraps an error so it can be stored in atomic.Value (which cannot store nil) type errorWrapper struct { err error @@ -246,18 +254,38 @@ type Manager struct { finalCommand string // Synchronization - mu sync.RWMutex - wg sync.WaitGroup - stopCh chan struct{} - doneCh chan struct{} - startMu sync.Mutex - admissionMu sync.Mutex - admissionCount int - admissionDrained chan struct{} - stopping bool - lifetimeCtx context.Context - lifetimeCancel context.CancelFunc - mainReapPending atomic.Bool + mu sync.RWMutex + wg sync.WaitGroup + stopCh chan struct{} + // stopChSnapshot mirrors stopCh for readers that must not take mu: Stop + // holds mu.Lock() for its entire teardown, including waiting for + // waitForExit to finish, so a stop-aware blocking send reached from that + // same goroutine (e.g. the exit-error send in waitForExit) would deadlock + // against Stop if it read stopCh under mu.RLock() instead. Start and + // startOneShot store the freshly created channel here in the same place + // they assign stopCh itself. + stopChSnapshot atomic.Value // chan struct{} + doneCh chan struct{} + // attachedCount is the live count of backend event-stream connections + // (see attachment.go). Zero value correctly starts an instance detached. + attachedCount atomic.Int32 + // turnOutcomeRecorder and turnOutcomeInstanceID back retained-outcome + // wiring (see turn_outcome.go). Both are guarded by mu: set once by + // SetTurnOutcomeRecorder before any goroutine that could read them is + // spawned (instance.Manager.CreateInstance calls it immediately after + // constructing this Manager, before Start can be reached), then read + // from forwardUpdates and sendUpdateBlocking's callers, neither of which + // otherwise holds mu. + turnOutcomeRecorder TurnOutcomeRecorder + turnOutcomeInstanceID string + startMu sync.Mutex + admissionMu sync.Mutex + admissionCount int + admissionDrained chan struct{} + stopping bool + lifetimeCtx context.Context + lifetimeCancel context.CancelFunc + mainReapPending atomic.Bool // stopChClosed guards close(stopCh), which is the only part of teardown // that is not naturally idempotent. It is reset wherever stopCh itself is // created so the flag always describes the current channel — a Start that @@ -371,11 +399,15 @@ func (m *Manager) BeginStop() { // NewManager creates a new process manager func NewManager(cfg *config.InstanceConfig, log *logger.Logger) *Manager { cfg.WorkDir = resolveExistingWorkDir(cfg.WorkDir, log.WithFields(zap.String("component", "process-manager"))) + updatesChannelCapacity := cfg.DetachedEventLimit + if updatesChannelCapacity <= 0 { + updatesChannelCapacity = defaultUpdatesChannelCapacity + } lifetimeCtx, lifetimeCancel := context.WithCancel(context.Background()) m := &Manager{ cfg: cfg, logger: log.WithFields(zap.String("component", "process-manager")), - updatesCh: make(chan adapter.AgentEvent, 100), + updatesCh: make(chan adapter.AgentEvent, updatesChannelCapacity), pendingPermissions: make(map[string]*PendingPermission), lifetimeCtx: lifetimeCtx, lifetimeCancel: lifetimeCancel, @@ -457,6 +489,15 @@ func (m *Manager) currentWorkspaceSourceRoots() []string { return append([]string(nil), m.workspaceSourceRoots...) } +// WorkspaceSourceRoots returns the live, current source-root allowlist +// (AC-EXECUTORS-SURVIVAL-002.14's "workspace source roots" reconstruction +// row: read back from the adopted instance, never pushed). A rescan or +// rebind changes this in place, so callers always see the value this +// instance is enforcing right now, not a snapshot from creation. +func (m *Manager) WorkspaceSourceRoots() []string { + return m.currentWorkspaceSourceRoots() +} + // lookupBaseBranch reads the task's recorded base branch for a given // repository name from the per-instance map. The empty key "" addresses the // single-repo / root tracker. Falls back to the empty-key entry when the @@ -1312,6 +1353,7 @@ func (m *Manager) Start(ctx context.Context) error { m.processLifecycle = processLifecycle m.stopCh = make(chan struct{}) + m.stopChSnapshot.Store(m.stopCh) m.doneCh = make(chan struct{}) m.stopChClosed.Store(false) @@ -1367,6 +1409,7 @@ func (m *Manager) startOneShot() error { } m.stopCh = make(chan struct{}) + m.stopChSnapshot.Store(m.stopCh) m.doneCh = make(chan struct{}) m.stopChClosed.Store(false) @@ -1904,6 +1947,7 @@ func (m *Manager) forwardUpdates(agentAdapter adapter.AgentAdapter, stopCh <-cha if !ok { return } + m.recordTerminalOutcome(&update) select { case m.updatesCh <- update: case <-stopCh: @@ -1929,21 +1973,21 @@ func (m *Manager) SendErrorEvent(errorMessage string, promptGeneration uint64) { // SendErrorEventWithProviderError sends an error event with optional safe // provider details. The details are already normalized by the adapter and are // never populated from the manager's raw stderr ring. +// +// This is a COVERED site (AC-EXECUTORS-SURVIVAL-001.5/.6): the agent ERROR +// event must park on a full channel rather than being dropped, since it is +// one of the events most likely to matter across a detached gap. func (m *Manager) SendErrorEventWithProviderError( errorMessage string, promptGeneration uint64, providerError *streams.ProviderError, ) { - select { - case m.updatesCh <- adapter.AgentEvent{ + m.sendUpdateBlocking(adapter.AgentEvent{ Type: adapter.EventTypeError, Error: errorMessage, PromptGeneration: promptGeneration, ProviderError: providerError, - }: - default: - m.logger.Warn("updates channel full, could not send error event") - } + }) } // PublishMCPAttachment forwards safe MCP attachment evidence through the @@ -2635,23 +2679,26 @@ func (m *Manager) waitForExit(stderrDone <-chan struct{}) { zap.Int("exit_code", exitCode), zap.Strings("recent_stderr", recentStderr)) - // Send error event to the updates channel so UI can display it + // Send error event to the updates channel so UI can display it. This is + // a COVERED site (AC-EXECUTORS-SURVIVAL-001.5/.6) and the only report + // that the agent process died at all, so it must park on a full + // channel rather than being dropped. sendUpdateBlocking selects + // against the stop-channel snapshot rather than m.mu-guarded state + // deliberately: Stop holds m.mu for its entire teardown, including + // waiting on this same waitForExit goroutine to finish, so reading + // stopCh under m.mu here would deadlock against it. errorMsg := fmt.Sprintf("Agent process exited with code %d", exitCode) if len(recentStderr) > 0 { errorMsg = fmt.Sprintf("%s: %s", errorMsg, strings.Join(recentStderr, "; ")) } - select { - case m.updatesCh <- adapter.AgentEvent{ + m.sendUpdateBlocking(adapter.AgentEvent{ Type: adapter.EventTypeError, Error: errorMsg, Data: map[string]any{ "exit_code": exitCode, "recent_stderr": recentStderr, }, - }: - default: - m.logger.Warn("updates channel full, could not send exit error event") - } + }) default: m.exitCode.Store(0) m.logger.Info("agent process exited successfully") @@ -2819,8 +2866,17 @@ func (m *Manager) autoApprovePermission(req *adapter.PermissionRequest) (*adapte } // sendPermissionNotification sends a permission request notification through the updates channel. -// Uses a blocking send with timeout to ensure delivery. If delivery fails within 5 seconds, -// auto-cancels the permission so the agent doesn't hang waiting for a response. +// +// This is the eighth COVERED site (AC-EXECUTORS-SURVIVAL-001.5/.6) and the +// only one of the eight that must branch on attached/detached state, rather +// than always parking: while ATTACHED, a blocking send with a five-second +// timeout is a deliberate safety valve against a stalled UI, and must +// survive unchanged -- auto-cancelling the permission so the agent doesn't +// hang waiting for a response. While DETACHED, that same auto-cancel would +// silently deny every permission the agent asks for, five seconds apart, so +// it must park instead: the wait ends either because a backend later +// attaches (which starts draining the channel, satisfying the same select +// sendUpdateBlocking already performs) or because the instance stops. func (m *Manager) sendPermissionNotification(pending *PendingPermission) { options := make([]streams.PermissionOption, len(pending.Snapshot.Options)) for i, option := range pending.Snapshot.Options { @@ -2846,6 +2902,11 @@ func (m *Manager) sendPermissionNotification(pending *PendingPermission) { zap.String("pending_id", pending.ID), zap.String("action_type", pending.Request.ActionType)) + if !m.IsAttached() { + m.sendUpdateBlocking(event) + return + } + timer := time.NewTimer(5 * time.Second) defer timer.Stop() select { @@ -2864,6 +2925,9 @@ func (m *Manager) sendPermissionNotification(pending *PendingPermission) { // sendPermissionCancelledNotification sends a notification that a permission request was cancelled. // This happens when the context is cancelled (e.g., agent completes or user stops the task) // before the user responds to the permission request. +// +// This is a COVERED site (AC-EXECUTORS-SURVIVAL-001.5/.6): it must park on a +// full channel rather than being dropped. func (m *Manager) sendPermissionCancelledNotification(pending *PendingPermission) { event := adapter.AgentEvent{ Type: adapter.EventTypePermissionCancelled, @@ -2876,13 +2940,7 @@ func (m *Manager) sendPermissionCancelledNotification(pending *PendingPermission zap.String("pending_id", pending.ID), zap.String("session_id", pending.Request.SessionID)) - select { - case m.updatesCh <- event: - // Sent successfully - default: - m.logger.Warn("updates channel full, dropping permission cancelled notification", - zap.String("pending_id", pending.ID)) - } + m.sendUpdateBlocking(event) } func (m *Manager) permissionSessionID(pending *PendingPermission) string { diff --git a/apps/backend/internal/agentctl/server/process/turn_outcome.go b/apps/backend/internal/agentctl/server/process/turn_outcome.go new file mode 100644 index 0000000000..35af326e37 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/turn_outcome.go @@ -0,0 +1,86 @@ +package process + +import "github.com/kandev/kandev/internal/agentctl/server/adapter" + +// TurnOutcomeRecorder retains the last terminal outcome observed for an +// instance so a backend that re-attaches after a restart +// (AC-EXECUTORS-SURVIVAL-004) can learn what happened to a turn it never saw +// complete. Satisfied by instance.Manager.RetainTurnOutcome; defined here +// (rather than imported from the instance package) because process must not +// import instance, which already imports process. +type TurnOutcomeRecorder interface { + RetainTurnOutcome(instanceID string, event adapter.AgentEvent) (turnID int64, ok bool) +} + +// turnOutcomeClearer is an optional extension implemented by the instance +// manager. A new prompt retires the previous terminal outcome before it can +// be mistaken for the prompt that is now in flight after a backend restart. +type turnOutcomeClearer interface { + ClearTurnOutcome(instanceID string, promptGeneration uint64) +} + +// SetTurnOutcomeRecorder wires the instance-level outcome retention sink for +// this process manager. Called once by instance.Manager.CreateInstance right +// after constructing the process manager and before Start can be reached +// through any path, mirroring the StderrProviderSetter optional-interface +// pattern used elsewhere in this package. A Manager with no recorder set +// (most existing tests, and the e2e harness) simply never retains -- +// retention is additive, not required for correct event delivery. +func (m *Manager) SetTurnOutcomeRecorder(instanceID string, recorder TurnOutcomeRecorder) { + m.mu.Lock() + defer m.mu.Unlock() + m.turnOutcomeInstanceID = instanceID + m.turnOutcomeRecorder = recorder +} + +// ClearTurnOutcome retires the previous terminal outcome before a new prompt +// is dispatched. The generation floor also rejects a late delivery of an +// older terminal event that races this clear operation. +func (m *Manager) ClearTurnOutcome(promptGeneration uint64) { + m.mu.RLock() + recorder := m.turnOutcomeRecorder + instanceID := m.turnOutcomeInstanceID + m.mu.RUnlock() + clearer, ok := recorder.(turnOutcomeClearer) + if !ok { + return + } + clearer.ClearTurnOutcome(instanceID, promptGeneration) +} + +// recordTerminalOutcome retains event if it is one of the two terminal event +// types AC-EXECUTORS-SURVIVAL-004 cares about (EventTypeComplete, +// EventTypeError) and a recorder has been wired. Every non-terminal event +// (permission request/cancelled, MCP attachment, context window, session +// models, ...) is filtered out here rather than at each producer, since +// EventTypeComplete/EventTypeError is emitted from several independent +// places (this manager's own agent-ERROR and process-exit-error sends, and +// the ACP adapter's session/prompt completion and protocol-error paths) that +// all converge on updatesCh regardless of origin -- this is the single +// filter for all of them, called from every point an event is actually +// delivered onto updatesCh (forwardUpdates, sendUpdateBlocking). +// +// event is a pointer so the assigned turn identifier can be stamped onto +// ControlTurnID before the caller sends this same value onward -- callers +// must call this before the channel send, not after, or the stamp never +// reaches the delivered copy. AC-EXECUTORS-SURVIVAL-004.4 requires that +// identifier to travel on both the retained-read observation (returned via +// TurnOutcome.TurnID) and the live-delivered observation (this stamp); the +// retained copy stored by Retain is deliberately the pre-stamp value, since +// TurnOutcome.TurnID already carries the identifier for that side. +func (m *Manager) recordTerminalOutcome(event *adapter.AgentEvent) { + if event.Type != adapter.EventTypeComplete && event.Type != adapter.EventTypeError { + return + } + m.mu.RLock() + recorder := m.turnOutcomeRecorder + instanceID := m.turnOutcomeInstanceID + m.mu.RUnlock() + if recorder == nil { + return + } + turnID, ok := recorder.RetainTurnOutcome(instanceID, *event) + if ok { + event.ControlTurnID = turnID + } +} diff --git a/apps/backend/internal/agentctl/server/process/turn_outcome_test.go b/apps/backend/internal/agentctl/server/process/turn_outcome_test.go new file mode 100644 index 0000000000..75ae7272ec --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/turn_outcome_test.go @@ -0,0 +1,271 @@ +package process + +import ( + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// fakeTurnOutcomeRecorder captures RetainTurnOutcome calls for assertion, +// standing in for instance.Manager without importing it (process must not +// import instance, which already imports process). +type fakeTurnOutcomeRecorder struct { + calls []fakeTurnOutcomeCall + clearCalls []fakeTurnOutcomeClearCall +} + +type fakeTurnOutcomeCall struct { + instanceID string + event adapter.AgentEvent +} + +type fakeTurnOutcomeClearCall struct { + instanceID string + promptGeneration uint64 +} + +func (f *fakeTurnOutcomeRecorder) RetainTurnOutcome(instanceID string, event adapter.AgentEvent) (int64, bool) { + f.calls = append(f.calls, fakeTurnOutcomeCall{instanceID: instanceID, event: event}) + return int64(len(f.calls)), true +} + +func (f *fakeTurnOutcomeRecorder) ClearTurnOutcome(instanceID string, promptGeneration uint64) { + f.clearCalls = append(f.clearCalls, fakeTurnOutcomeClearCall{ + instanceID: instanceID, + promptGeneration: promptGeneration, + }) +} + +func TestClearTurnOutcomeForwardsPromptGenerationToRecorder(t *testing.T) { + recorder := &fakeTurnOutcomeRecorder{} + m := &Manager{} + m.SetTurnOutcomeRecorder("instance-1", recorder) + + m.ClearTurnOutcome(9) + + if len(recorder.clearCalls) != 1 { + t.Fatalf("clear calls = %d, want 1", len(recorder.clearCalls)) + } + call := recorder.clearCalls[0] + if call.instanceID != "instance-1" || call.promptGeneration != 9 { + t.Fatalf("clear call = %+v, want instance-1/generation-9", call) + } +} + +// TestRecordTerminalOutcomeRetainsCompleteAndErrorEvents pins +// AC-EXECUTORS-SURVIVAL-004.1: the two terminal event types must reach the +// wired recorder with the instance ID SetTurnOutcomeRecorder was given. +func TestRecordTerminalOutcomeRetainsCompleteAndErrorEvents(t *testing.T) { + terminalTypes := []string{adapter.EventTypeComplete, adapter.EventTypeError} + for _, eventType := range terminalTypes { + t.Run(eventType, func(t *testing.T) { + recorder := &fakeTurnOutcomeRecorder{} + m := &Manager{} + m.SetTurnOutcomeRecorder("instance-1", recorder) + + event := adapter.AgentEvent{Type: eventType} + m.recordTerminalOutcome(&event) + + if len(recorder.calls) != 1 { + t.Fatalf("recorder calls = %d, want 1", len(recorder.calls)) + } + if recorder.calls[0].instanceID != "instance-1" { + t.Fatalf("instanceID = %q, want instance-1", recorder.calls[0].instanceID) + } + if recorder.calls[0].event.Type != eventType { + t.Fatalf("event type = %q, want %q", recorder.calls[0].event.Type, eventType) + } + if event.ControlTurnID != int64(len(recorder.calls)) { + t.Fatalf("event.ControlTurnID = %d, want %d (stamped from RetainTurnOutcome's return)", + event.ControlTurnID, len(recorder.calls)) + } + }) + } +} + +// TestRecordTerminalOutcomeIgnoresNonTerminalEvents pins that every other +// event type -- including the two excluded MCP-attachment sites' type and +// the permission lifecycle types -- must never reach the recorder. AC-004 is +// specifically "what happened to my last turn," not every lifecycle event. +func TestRecordTerminalOutcomeIgnoresNonTerminalEvents(t *testing.T) { + nonTerminalTypes := []string{ + adapter.EventTypePermissionRequest, + adapter.EventTypePermissionCancelled, + streams.EventTypeMCPAttachment, + "context_window", + "session_models", + "message_chunk", + } + for _, eventType := range nonTerminalTypes { + t.Run(eventType, func(t *testing.T) { + recorder := &fakeTurnOutcomeRecorder{} + m := &Manager{} + m.SetTurnOutcomeRecorder("instance-1", recorder) + + event := adapter.AgentEvent{Type: eventType} + m.recordTerminalOutcome(&event) + + if len(recorder.calls) != 0 { + t.Fatalf("recorder calls = %d for type %q, want 0", len(recorder.calls), eventType) + } + }) + } +} + +// TestRecordTerminalOutcomeNoopWithoutRecorder pins that a Manager with no +// recorder wired (the common case: most tests, and the e2e harness) safely +// ignores terminal events instead of panicking. +func TestRecordTerminalOutcomeNoopWithoutRecorder(t *testing.T) { + m := &Manager{} + event := adapter.AgentEvent{Type: adapter.EventTypeComplete} + m.recordTerminalOutcome(&event) + if event.ControlTurnID != 0 { + t.Fatalf("ControlTurnID = %d, want 0 with no recorder wired", event.ControlTurnID) + } +} + +// TestSendUpdateBlockingRecordsTerminalOutcomeOnDelivery pins that both the +// fast path (room available) and the parked path (room freed up later) of +// sendUpdateBlocking retain a terminal event on successful delivery. +func TestSendUpdateBlockingRecordsTerminalOutcomeOnDelivery(t *testing.T) { + t.Run("fast path", func(t *testing.T) { + recorder := &fakeTurnOutcomeRecorder{} + m := &Manager{updatesCh: make(chan adapter.AgentEvent, 1)} + m.SetTurnOutcomeRecorder("instance-1", recorder) + + if !m.sendUpdateBlocking(adapter.AgentEvent{Type: adapter.EventTypeError}) { + t.Fatal("sendUpdateBlocking returned false with room available") + } + if len(recorder.calls) != 1 { + t.Fatalf("recorder calls = %d, want 1", len(recorder.calls)) + } + }) + + t.Run("parked path", func(t *testing.T) { + recorder := &fakeTurnOutcomeRecorder{} + m := &Manager{updatesCh: make(chan adapter.AgentEvent, 1)} + m.SetTurnOutcomeRecorder("instance-1", recorder) + m.stopChSnapshot.Store(make(chan struct{})) + m.updatesCh <- adapter.AgentEvent{Type: adapter.EventTypeComplete} // fill the buffer + + done := make(chan bool, 1) + go func() { done <- m.sendUpdateBlocking(adapter.AgentEvent{Type: adapter.EventTypeError}) }() + + select { + case <-done: + t.Fatal("sendUpdateBlocking returned before the channel had room") + case <-time.After(50 * time.Millisecond): + } + + <-m.updatesCh // drain the pre-filled slot + + select { + case ok := <-done: + if !ok { + t.Fatal("sendUpdateBlocking returned false after room freed up") + } + case <-time.After(time.Second): + t.Fatal("sendUpdateBlocking did not return promptly after room freed up") + } + if len(recorder.calls) != 1 { + t.Fatalf("recorder calls = %d, want 1", len(recorder.calls)) + } + }) +} + +// TestForwardUpdatesRecordsTerminalOutcomeForAdapterOriginatedEvents pins +// that a terminal event emitted by the ACP adapter (session/prompt +// completion, protocol-level error) -- which never goes through +// sendUpdateBlocking, only through forwardUpdates's copy from the adapter's +// own channel -- is also retained. This is the coverage this manager cannot +// get any other way, since the adapter has no direct reference to the +// recorder. +func TestForwardUpdatesRecordsTerminalOutcomeForAdapterOriginatedEvents(t *testing.T) { + recorder := &fakeTurnOutcomeRecorder{} + stopCh := make(chan struct{}) + stub := newStubAdapter() + m := &Manager{ + adapter: stub, + updatesCh: make(chan adapter.AgentEvent, 10), + stopCh: stopCh, + logger: newTestLogger(t), + } + m.SetTurnOutcomeRecorder("instance-1", recorder) + m.wg.Add(1) + go m.forwardUpdates(stub, stopCh) + t.Cleanup(func() { close(stopCh) }) + + stub.updatesCh <- adapter.AgentEvent{Type: adapter.EventTypeComplete} + stub.updatesCh <- adapter.AgentEvent{Type: adapter.EventTypeMessageChunk} + + select { + case <-m.updatesCh: + case <-time.After(time.Second): + t.Fatal("timed out waiting for the complete event to be forwarded") + } + select { + case <-m.updatesCh: + case <-time.After(time.Second): + t.Fatal("timed out waiting for the message_chunk event to be forwarded") + } + + if len(recorder.calls) != 1 { + t.Fatalf("recorder calls = %d, want 1 (only the complete event)", len(recorder.calls)) + } + if recorder.calls[0].event.Type != adapter.EventTypeComplete { + t.Fatalf("retained event type = %q, want complete", recorder.calls[0].event.Type) + } +} + +// TestSendUpdateBlockingStampsControlTurnIDOnDeliveredCopy and +// TestForwardUpdatesStampsControlTurnIDOnDeliveredCopy pin +// AC-EXECUTORS-SURVIVAL-004.4: the control-server-assigned turn identifier +// must reach the copy actually delivered on m.updatesCh (the "delivered +// event" observation), not just the pre-stamp copy Retain stores (the +// "retained turn status" observation, which carries it via +// TurnOutcome.TurnID instead). Without this stamp a backend re-attaching +// after a restart has no way to recognize a live redelivery of the exact +// turn it already applied from GetTurnOutcome. +func TestSendUpdateBlockingStampsControlTurnIDOnDeliveredCopy(t *testing.T) { + recorder := &fakeTurnOutcomeRecorder{} + m := &Manager{updatesCh: make(chan adapter.AgentEvent, 1)} + m.SetTurnOutcomeRecorder("instance-1", recorder) + + if !m.sendUpdateBlocking(adapter.AgentEvent{Type: adapter.EventTypeError}) { + t.Fatal("sendUpdateBlocking returned false with room available") + } + + delivered := <-m.updatesCh + if delivered.ControlTurnID != 1 { + t.Fatalf("delivered.ControlTurnID = %d, want 1", delivered.ControlTurnID) + } +} + +func TestForwardUpdatesStampsControlTurnIDOnDeliveredCopy(t *testing.T) { + recorder := &fakeTurnOutcomeRecorder{} + stopCh := make(chan struct{}) + stub := newStubAdapter() + m := &Manager{ + adapter: stub, + updatesCh: make(chan adapter.AgentEvent, 10), + stopCh: stopCh, + logger: newTestLogger(t), + } + m.SetTurnOutcomeRecorder("instance-1", recorder) + m.wg.Add(1) + go m.forwardUpdates(stub, stopCh) + t.Cleanup(func() { close(stopCh) }) + + stub.updatesCh <- adapter.AgentEvent{Type: adapter.EventTypeComplete} + + select { + case delivered := <-m.updatesCh: + if delivered.ControlTurnID != 1 { + t.Fatalf("delivered.ControlTurnID = %d, want 1", delivered.ControlTurnID) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for the complete event to be forwarded") + } +} diff --git a/apps/backend/internal/agentctl/server/process/updates_channel_capacity_test.go b/apps/backend/internal/agentctl/server/process/updates_channel_capacity_test.go new file mode 100644 index 0000000000..09d2f9deff --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/updates_channel_capacity_test.go @@ -0,0 +1,35 @@ +package process + +import ( + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/config" +) + +// TestNewManagerSizesUpdatesChannelFromDetachedEventLimit pins +// AC-EXECUTORS-SURVIVAL-001.6: the per-instance retained-event limit is the +// updates channel's own buffer, not an incidental hardcoded size, so a +// non-default configured value must be honoured. +func TestNewManagerSizesUpdatesChannelFromDetachedEventLimit(t *testing.T) { + mgr := NewManager(&config.InstanceConfig{ + WorkDir: t.TempDir(), + DetachedEventLimit: 7, + }, newTestLogger(t)) + + if cap(mgr.updatesCh) != 7 { + t.Fatalf("cap(updatesCh) = %d, want 7 (from DetachedEventLimit)", cap(mgr.updatesCh)) + } +} + +// TestNewManagerDefaultsUpdatesChannelCapacityWhenUnset pins the fallback for +// the many call sites (including most of this package's own tests) that +// construct config.InstanceConfig directly without going through +// Config.NewInstanceConfig, so DetachedEventLimit is left at its Go +// zero value rather than the configured default of 100. +func TestNewManagerDefaultsUpdatesChannelCapacityWhenUnset(t *testing.T) { + mgr := NewManager(&config.InstanceConfig{WorkDir: t.TempDir()}, newTestLogger(t)) + + if cap(mgr.updatesCh) != defaultUpdatesChannelCapacity { + t.Fatalf("cap(updatesCh) = %d, want the default %d", cap(mgr.updatesCh), defaultUpdatesChannelCapacity) + } +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_files_test.go b/apps/backend/internal/agentctl/server/process/workspace_files_test.go index ad611f8e75..9bcce1a039 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files_test.go @@ -213,6 +213,43 @@ func TestWorkspaceFileOperationsAllowRegisteredLinkedSource(t *testing.T) { } } +// TestWorkspaceFileOperationsWithNoAllowedSourceRootsFailClosed pins the +// AC-EXECUTORS-SURVIVAL-002.14 recovery contract: when the adopted instance +// reports zero workspace source roots (nil/empty), the tracker must reject +// every durable-source symlink escape rather than treating "no roots +// configured" as "no restriction". Ordinary in-workspace file operations are +// unaffected, since they never need the allowlist. +func TestWorkspaceFileOperationsWithNoAllowedSourceRootsFailClosed(t *testing.T) { + workspace := t.TempDir() + source := t.TempDir() + if err := os.Symlink(source, filepath.Join(workspace, "linked")); err != nil { + t.Skip("symlinks not supported") + } + + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatal(err) + } + wt := &WorkspaceTracker{workDir: workspace, logger: log} + // Deliberately never call SetAllowedSourceRoots, reproducing the + // zero-value state a freshly recovered tracker has before any roots are + // (re)applied from the adopted instance. + + if err := wt.CreateFile(filepath.Join("linked", "escape.txt")); err == nil { + t.Fatal("CreateFile through an unregistered symlink unexpectedly succeeded with no allowed source roots") + } + if _, err := os.Stat(filepath.Join(source, "escape.txt")); !os.IsNotExist(err) { + t.Fatalf("file leaked into the symlink target despite no allowed source roots: %v", err) + } + + if err := wt.CreateFile("plain.txt"); err != nil { + t.Fatalf("CreateFile for an ordinary in-workspace path: %v", err) + } + if _, err := os.Stat(filepath.Join(workspace, "plain.txt")); err != nil { + t.Fatalf("in-workspace file was not created: %v", err) + } +} + func TestWorkspaceFileMutationsRejectDescendantSymlinkSwap(t *testing.T) { for _, tc := range []struct { name string diff --git a/apps/backend/internal/agentctl/types/streams/agent.go b/apps/backend/internal/agentctl/types/streams/agent.go index eb87eec9dc..d3465f2aeb 100644 --- a/apps/backend/internal/agentctl/types/streams/agent.go +++ b/apps/backend/internal/agentctl/types/streams/agent.go @@ -143,6 +143,18 @@ type AgentEvent struct { // ownership from cross-subject event ordering. TurnID string `json:"turn_id,omitempty"` + // ControlTurnID is the control-server-assigned turn identifier + // (AC-EXECUTORS-SURVIVAL-004.1), stamped onto a terminal event at the + // same point the control server retains it as that instance's turn + // outcome (internal/agentctl/server/process.recordTerminalOutcome). Zero + // means "not retained" -- the sequence that assigns it starts at 1 + // (instance.Manager.turnIDSeq). AC-EXECUTORS-SURVIVAL-004.4 requires the + // dedup decision between a retrieved outcome and a live-delivered event + // for the same turn to be made on this identifier, since it -- unlike + // PromptGeneration -- travels on both observations and survives a + // restart that resets the backend's own in-memory generation counter. + ControlTurnID int64 `json:"control_turn_id,omitempty"` + // --- Message fields (for "message_chunk" type) --- // Text contains streaming text content from the agent. diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index 62f5f86bb3..05688fe140 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -325,6 +325,23 @@ func newLifecycleAdapter(mgr *lifecycle.Manager, reg *registry.Registry, log *lo // LaunchAgent creates a new agentctl instance for a task. // Agent subprocess is NOT started - call StartAgentProcess() explicitly. +// wrapSessionRecoveryGuardError translates the raw lifecycle recovery-guard +// sentinels into an orchestrator.SessionRecoveryGuardError so callers outside +// internal/agent/runtime/ can distinguish a retryable in-progress recovery +// from a non-retryable unstoppable-agent condition without importing +// lifecycle directly. Errors that are not guard-related pass through +// unchanged. +func wrapSessionRecoveryGuardError(err error, sessionID string) error { + switch { + case errors.Is(err, lifecycle.ErrSessionRecoveryGuarded): + return &orchestrator.SessionRecoveryGuardError{Cause: err, SessionID: sessionID, Retryable: true} + case errors.Is(err, lifecycle.ErrSessionUnstoppableAgent): + return &orchestrator.SessionRecoveryGuardError{Cause: err, SessionID: sessionID, Retryable: false} + default: + return err + } +} + func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { // WorkspacePath wins when set (repo-less task with picked folder); otherwise // fall back to RepositoryURL (legacy: this carries a local filesystem path @@ -343,7 +360,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch // Create the agentctl execution (does NOT start agent process) execution, err := a.mgr.Launch(ctx, launchReq) if err != nil { - return nil, err + return nil, wrapSessionRecoveryGuardError(err, req.SessionID) } // Extract worktree info from metadata if available @@ -633,13 +650,29 @@ func normalizeRuntimeStopError(err error) error { } // RowLiveness classifies the liveness of the OS process backing an -// executors_running row using the runtime-aware host-local probe. It is the -// orchestrator's window into the platform-split liveness check (kept in the -// lifecycle package) used by startup reconciliation -// (#1597 runtime-aware liveness). A local check never runs against a +// executors_running row. It is the orchestrator's window into the +// platform-split liveness check (kept in the lifecycle package) used outside +// a reconciliation pass — e.g. the single-session idle reclaim path — and +// always takes its own fresh adopted-server enumeration for a standalone row +// rather than reusing one a pass cached (see RowLivenessScoped for the +// pass-scoped sibling). A local/enumeration check never runs against a // remote/SSH row — such rows return Unknown. func (a *lifecycleAdapter) RowLiveness(row *models.ExecutorRunning) models.ProcessLiveness { - return lifecycle.RowProcessLiveness(row) + return a.mgr.RowLiveness(row) +} + +// NewStandaloneLivenessScope takes one adopted-server enumeration for reuse +// across every row of a single reconciliation pass (design 02 "Persistence": +// "It has two kinds of caller, and only one of them is a pass"). Satisfies +// the orchestrator's optional standaloneLivenessScoper capability. +func (a *lifecycleAdapter) NewStandaloneLivenessScope(ctx context.Context) interface{} { + return a.mgr.NewStandaloneLivenessScope(ctx) +} + +// RowLivenessScoped classifies row's liveness reusing scope (from +// NewStandaloneLivenessScope) instead of taking a fresh enumeration. +func (a *lifecycleAdapter) RowLivenessScoped(row *models.ExecutorRunning, scope interface{}) models.ProcessLiveness { + return a.mgr.RowLivenessScoped(row, scope) } // GetAgentStatus returns the status of an agent execution diff --git a/apps/backend/internal/backendapp/adapters_kubernetes_launch_test.go b/apps/backend/internal/backendapp/adapters_kubernetes_launch_test.go index 981e4f0f3e..42990b25d6 100644 --- a/apps/backend/internal/backendapp/adapters_kubernetes_launch_test.go +++ b/apps/backend/internal/backendapp/adapters_kubernetes_launch_test.go @@ -41,7 +41,7 @@ func (b *capturingKubernetesBackend) CreateInstance( func (b *capturingKubernetesBackend) StopInstance(context.Context, *lifecycle.ExecutorInstance, bool) error { return nil } -func (b *capturingKubernetesBackend) RecoverInstances(context.Context) ([]*lifecycle.ExecutorInstance, error) { +func (b *capturingKubernetesBackend) RecoverInstances(context.Context, []*models.ExecutorRunning) ([]*lifecycle.ExecutorInstance, error) { return nil, nil } func (b *capturingKubernetesBackend) GetInteractiveRunner() *process.InteractiveRunner { return nil } diff --git a/apps/backend/internal/backendapp/agentctl.go b/apps/backend/internal/backendapp/agentctl.go index 81c2a288b7..fc6345d693 100644 --- a/apps/backend/internal/backendapp/agentctl.go +++ b/apps/backend/internal/backendapp/agentctl.go @@ -2,12 +2,18 @@ package backendapp import ( "context" + "fmt" + "net" + "strconv" "time" + agentruntime "github.com/kandev/kandev/internal/agent/runtime" agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/agent/runtime/agentctl/launcher" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/common/ownershipperiod" + "github.com/kandev/kandev/internal/secrets" "go.uber.org/zap" ) @@ -15,18 +21,167 @@ import ( type agentctlLauncherResult struct { cleanup func() error binaryPath string + // recoveryDeadlineStart is the AC-EXECUTORS-SURVIVAL-003.7 recovery + // deadline's clock start (the instant this launch first contacted a + // recorded control endpoint). Zero when this launch never contacted one + // (adoption disabled, no record, or a fresh spawn) -- the lifecycle + // manager falls back to its own Start-time default in that case. + recoveryDeadlineStart time.Time + // inheritedRecordScope is what this launch found at the recorded control + // endpoint. It governs how recovery classifies recovery-inventory records + // left by an earlier launch, which cannot be judged against a server this + // backend started itself. + inheritedRecordScope agentruntime.InheritedRecordScope } -// provideAgentctlLauncher starts the agentctl launcher for standalone runtime. -// agentctl is a core service that always runs - it's used by the Standalone runtime -// for agent execution on the host machine. -// If the configured port is unavailable, the launcher may fall back to an OS-assigned -// port. In that case, cfg.Agent.StandalonePort is updated to reflect the actual port. +// provideAgentctlLauncher starts or adopts the agentctl control server for +// standalone runtime. agentctl is a core service that always runs - it's used +// by the Standalone runtime for agent execution on the host machine. +// +// When the agent-survival capability is enabled (Layer 6.1 of +// agent-survival-across-restart), a surviving control server from a prior +// launch is tried first via agentruntime.AttemptAdoptControlServer; only when +// that fails or there is no record does this spawn a fresh one, same as +// when the capability is disabled. Either way, cfg.Agent.Standalone* is +// updated to point at whichever server this launch ends up using. func provideAgentctlLauncher( ctx context.Context, cfg *config.Config, log *logger.Logger, availability *agentctlclient.Availability, + store agentruntime.AdoptionRecordStore, + secretStore secrets.SecretStore, +) (*agentctlLauncherResult, error) { + result, scope := resolveSurvivingAgentctl(ctx, cfg, log, store, secretStore) + if result != nil { + availability.MarkAvailable() + return result, nil + } + fresh, err := spawnFreshAgentctl(ctx, cfg, log, availability, store, secretStore) + if fresh != nil { + // The server this launch just started never launched the instances a + // prior launch recorded, so recovery needs to know what was found at + // the recorded endpoint rather than judging them against it. + fresh.inheritedRecordScope = scope + } + return fresh, err +} + +// resolveSurvivingAgentctl applies the agent-survival capability gate to a +// control server a prior launch left running, and returns non-nil only when +// this launch took that server over. With the capability disabled nothing is +// ever adopted, but a server an earlier survival-enabled launch detached is +// still running with no backend attached to it, so it is stopped here rather +// than left to its own unowned-shutdown timer. Either way a nil return means +// the caller spawns fresh. +func resolveSurvivingAgentctl( + ctx context.Context, + cfg *config.Config, + log *logger.Logger, + store agentruntime.AdoptionRecordStore, + secretStore secrets.SecretStore, +) (*agentctlLauncherResult, agentruntime.InheritedRecordScope) { + if !cfg.Features.AgentSurvival { + agentruntime.ReclaimUnneededControlServer(ctx, store, secretStore, controlClientFactory(log), cfg.ResolvedHomeDir(), + cfg.Agentctl.RecoveryReadTimeout, cfg.Agentctl.RecoveryReadRetries, log) + return nil, agentruntime.InheritedRecordScopeNoServer + } + return adoptSurvivingAgentctl(ctx, cfg, log, store, secretStore) +} + +// refusedAdoptionScope maps a refused adoption to what recovery may conclude +// about records an earlier launch left behind. A refusal that never reached +// the server -- nothing answered at the recorded endpoint, or no endpoint was +// recorded -- means no control server holds those instances, so they can be +// probed and repaired. Every refusal that did reach a reachable server leaves +// that server running, and an instance may still be alive on it, so nothing +// may be concluded from its absence from a server this launch started. +func refusedAdoptionScope(outcome agentruntime.AdoptionOutcome) agentruntime.InheritedRecordScope { + if outcome.ContactedAt.IsZero() { + return agentruntime.InheritedRecordScopeNoServer + } + return agentruntime.InheritedRecordScopeForeignServer +} + +// adoptSurvivingAgentctl attempts to adopt a control server recorded by a +// prior launch. Returns nil when adoption did not complete for any reason +// (design 02's "Startup" steps 4-6): the caller then falls back to spawning +// a fresh server exactly as it would with the capability disabled. +func adoptSurvivingAgentctl( + ctx context.Context, + cfg *config.Config, + log *logger.Logger, + store agentruntime.AdoptionRecordStore, + secretStore secrets.SecretStore, +) (*agentctlLauncherResult, agentruntime.InheritedRecordScope) { + outcome := agentruntime.AttemptAdoptControlServer(ctx, store, secretStore, controlClientFactory(log), + cfg.ResolvedHomeDir(), agentruntime.RequiredSurvivalCapabilities, + cfg.Agentctl.RecoveryReadTimeout, cfg.Agentctl.RecoveryReadRetries, log) + if !outcome.Adopted { + log.Info("control server adoption did not complete; spawning a fresh one", + zap.String("reason", string(outcome.Reason))) + return nil, refusedAdoptionScope(outcome) + } + + host, port, err := splitEndpoint(outcome.Endpoint) + if err != nil { + log.Warn("adopted control server endpoint is malformed; spawning fresh instead", zap.Error(err)) + return nil, refusedAdoptionScope(outcome) + } + + log.Info("adopted a surviving control server", zap.String("endpoint", outcome.Endpoint)) + cfg.Agent.StandaloneHost = host + cfg.Agent.StandalonePort = port + cfg.Agent.StandaloneAuthToken = outcome.Credential + // This process never spawned the adopted server, so it has no PID to + // record here. Standalone liveness for an adopted record is judged by + // enumeration against the adopted server (design 02, "Persistence"), + // not by a stale PID probe -- a later layer of this feature. + cfg.Agent.StandalonePID = 0 + + // AC-EXECUTORS-CONTROL-OWNERSHIP-003.8: the rotation AttemptAdoptControlServer + // just performed already counted as the first renewal, but nothing renews + // ownership again after that without this loop. The renewal cadence is + // computed from the adopted server's OWN reported unowned period + // (outcome.UnownedPeriod), never this launch's local config: the two can + // disagree across a restart that changed agentctl.unownedPeriod, and + // renewing on a stale/mismatched cadence lets the server's own reaper + // fire while this backend still believes it owns it (Review round 3, + // finding 5). + renewer := startOwnershipRenewal(ctx, log, outcome.Endpoint, outcome.Credential, + resolveAdoptedRenewalPeriod(outcome.UnownedPeriod)) + + return &agentctlLauncherResult{ + // Survival is enabled and this server was adopted, not spawned: the + // registered cleanup must not stop it (AC-EXECUTORS-SURVIVAL-001.1), + // and there is no local process for this launcher to own regardless. + // It must still stop the renewal loop this backend started, or the + // goroutine leaks past backend shutdown. + cleanup: func() error { + if renewer != nil { + renewer.Stop() + } + return nil + }, + binaryPath: launcher.FindAgentctlBinary(), + recoveryDeadlineStart: outcome.ContactedAt, + inheritedRecordScope: agentruntime.InheritedRecordScopeAdopted, + }, agentruntime.InheritedRecordScopeAdopted +} + +// spawnFreshAgentctl is today's unconditional launch path. If the +// agent-survival capability is enabled, the freshly spawned server is also +// durably recorded as the installation's control server (failure-table row +// "Own server started after a refused or failed adoption"); recording +// failure is logged and non-fatal to startup, since the next restart simply +// finds no record and spawns fresh again. +func spawnFreshAgentctl( + ctx context.Context, + cfg *config.Config, + log *logger.Logger, + availability *agentctlclient.Availability, + store agentruntime.AdoptionRecordStore, + secretStore secrets.SecretStore, ) (*agentctlLauncherResult, error) { l, cleanup, err := launcher.Provide(ctx, launcher.Config{ Host: cfg.Agent.StandaloneHost, @@ -45,17 +200,114 @@ func provideAgentctlLauncher( zap.Int("actual_port", actualPort)) cfg.Agent.StandalonePort = actualPort } - // Store the per-launch auth token so downstream clients can authenticate + // Store the per-launch auth token so downstream clients can authenticate. cfg.Agent.StandaloneAuthToken = l.AuthToken() // Store the agentctl control-server PID so local/standalone executor rows can // carry a real host-local liveness handle (executors_running.local_pid). cfg.Agent.StandalonePID = l.Pid() + + var renewer *agentruntime.OwnershipRenewer + if cfg.Features.AgentSurvival { + endpoint := net.JoinHostPort(cfg.Agent.StandaloneHost, strconv.Itoa(cfg.Agent.StandalonePort)) + client, err := controlClientFactory(log)(endpoint) + if err != nil { + log.Warn("failed to build control client for the freshly spawned agentctl; adoption record not written", zap.Error(err)) + } else if err := agentruntime.RecordFreshControlServer(ctx, store, secretStore, client, endpoint, l.AuthToken()); err != nil { + log.Warn("failed to record freshly spawned control server", zap.Error(err)) + } + // AC-EXECUTORS-CONTROL-OWNERSHIP-003.8: the bootstrap handshake this + // launch just completed already counted as the first renewal, but + // nothing renews ownership again after that without this loop. This + // launch spawned the server moments ago from this same config, so + // (unlike the adopted path above) the local resolution IS the value + // the server itself enforces. + period, _ := ownershipperiod.Resolve(cfg.Agentctl.UnownedPeriod, cfg.Agentctl.IdleTimeout) + renewer = startOwnershipRenewal(ctx, log, endpoint, l.AuthToken(), period) + } + return &agentctlLauncherResult{ - cleanup: cleanup, + cleanup: func() error { + if renewer != nil { + renewer.Stop() + } + return cleanup() + }, binaryPath: l.BinaryPath(), }, nil } +// startOwnershipRenewal builds a dedicated control client authenticated with +// the given credential and starts the periodic ownership-claim loop against +// it (AC-EXECUTORS-CONTROL-OWNERSHIP-003.2/.8), computing the renewal +// interval from the given already-resolved unowned period. The caller +// decides where that period comes from: this launch's own local config is +// only correct for a server this launch just spawned itself -- an adopted +// server may have been spawned by a prior launch with different config, so +// its renewal period must come from what that server itself reports (see +// resolveAdoptedRenewalPeriod). Returns nil (logged, not fatal) when the +// endpoint can't be parsed; startup already succeeded by this point, so +// refusing to run the surviving server over a renewal-loop failure would +// trade a smaller problem for a bigger one. +func startOwnershipRenewal( + ctx context.Context, + log *logger.Logger, + endpoint string, + credential string, + period time.Duration, +) *agentruntime.OwnershipRenewer { + host, port, err := splitEndpoint(endpoint) + if err != nil { + log.Warn("failed to start ownership renewal loop; endpoint malformed", zap.Error(err)) + return nil + } + client := agentctlclient.NewControlClient(host, port, log) + client.SetAuthToken(credential) + renewer := agentruntime.NewOwnershipRenewer(client, ownershipperiod.RenewalInterval(period), log) + renewer.Start(ctx) + return renewer +} + +// resolveAdoptedRenewalPeriod picks the unowned period an adopted server's +// ownership-renewal loop renews against: the value that server itself +// reported on /identity during adoption (outcome.UnownedPeriod), never this +// launch's local config -- the two can disagree across a restart that +// changed agentctl.unownedPeriod, and renewing on this launch's own +// (possibly longer) cadence lets the adopted server's own reaper fire while +// this backend still believes it owns it. Falls back to the shared floor +// only for a legacy pre-upgrade server that answered /identity without an +// unowned_period_ms field at all (reported as zero). +func resolveAdoptedRenewalPeriod(reported time.Duration) time.Duration { + if reported <= 0 { + return ownershipperiod.MinPeriod + } + return reported +} + +// controlClientFactory builds the real agentruntime.AdoptionControlClientFactory +// used in production: it parses "host:port" and returns a live +// agentctlclient.ControlClient targeting it. +func controlClientFactory(log *logger.Logger) agentruntime.AdoptionControlClientFactory { + return func(endpoint string) (agentruntime.AdoptionControlClient, error) { + host, port, err := splitEndpoint(endpoint) + if err != nil { + return nil, err + } + return agentctlclient.NewControlClient(host, port, log), nil + } +} + +func splitEndpoint(endpoint string) (host string, port int, err error) { + host, portStr, err := net.SplitHostPort(endpoint) + if err != nil { + return "", 0, fmt.Errorf("split control server endpoint %q: %w", endpoint, err) + } + port, err = strconv.Atoi(portStr) + if err != nil { + return "", 0, fmt.Errorf("parse control server port from endpoint %q: %w", endpoint, err) + } + return host, port, nil +} + // waitForAgentctlControlHealthy waits for the agentctl control server to be healthy. // This is called during startup to ensure agentctl is ready before accepting requests. func waitForAgentctlControlHealthy(ctx context.Context, cfg *config.Config, log *logger.Logger) { diff --git a/apps/backend/internal/backendapp/agentctl_survival_gate_test.go b/apps/backend/internal/backendapp/agentctl_survival_gate_test.go new file mode 100644 index 0000000000..f8833fc811 --- /dev/null +++ b/apps/backend/internal/backendapp/agentctl_survival_gate_test.go @@ -0,0 +1,165 @@ +package backendapp + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/ownershipproof" + "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/task/models" +) + +const ( + gateTestHomeDir = "/home/kandev-gate-test" + gateSeedCredential = "bootstrap-credential" +) + +// recordingControlServer stands in for a control server left running by a +// prior launch and records which ownership operation this launch performed +// against it. Adoption rotates the credential; reclamation shuts the server +// down. The two are mutually exclusive, so which one arrives is the whole +// observation. +type recordingControlServer struct { + *httptest.Server + mu sync.Mutex + rotated bool + shutDown bool + identity string + challenges []string +} + +func newRecordingControlServer(t *testing.T) *recordingControlServer { + t.Helper() + rec := &recordingControlServer{identity: "gate-survivor-identity"} + rec.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/identity": + _ = json.NewEncoder(w).Encode(agentctlclient.IdentityInfo{ + ServerIdentity: rec.identity, + Capabilities: lifecycle.RequiredSurvivalCapabilities, + }) + case r.Method == http.MethodPost && r.URL.Path == "/ownership/prove": + var req struct { + Challenge string `json:"challenge"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + rec.mu.Lock() + rec.challenges = append(rec.challenges, req.Challenge) + rec.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string][]string{ + "proofs": {ownershipproof.Derive(gateSeedCredential, req.Challenge, gateTestHomeDir)}, + }) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/ownership/details": + _ = json.NewEncoder(w).Encode(agentctlclient.ServerDetails{HomeDir: gateTestHomeDir}) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/ownership/rotate": + rec.mu.Lock() + rec.rotated = true + rec.mu.Unlock() + _ = json.NewEncoder(w).Encode(agentctlclient.CredentialRotationResult{ + RotationID: 1, + Credential: "gate-rotated-credential", + }) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/ownership/confirm": + w.WriteHeader(http.StatusOK) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/ownership/shutdown": + rec.mu.Lock() + rec.shutDown = true + rec.mu.Unlock() + w.WriteHeader(http.StatusOK) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(rec.Close) + return rec +} + +func (r *recordingControlServer) observed() (rotated, shutDown bool) { + r.mu.Lock() + defer r.mu.Unlock() + return r.rotated, r.shutDown +} + +// newGateFixture wires a recorded surviving control server to a store and a +// secret store holding the credential that server was launched with. +func newGateFixture(t *testing.T) (*recordingControlServer, *fakeControlServerStore, secrets.SecretStore, *config.Config) { + t.Helper() + server := newRecordingControlServer(t) + + secretStore := newIDGeneratingSecretStore() + seed := secrets.SecretWithValue{} + seed.Name = "gate-seed" + seed.Value = gateSeedCredential + if err := secretStore.Create(context.Background(), &seed); err != nil { + t.Fatalf("seed secret: %v", err) + } + + store := &fakeControlServerStore{record: &models.ControlServerRecord{ + Endpoint: server.Listener.Addr().String(), + ServerIdentity: server.identity, + CredentialSecretID: seed.ID, + }} + + cfg := &config.Config{} + cfg.HomeDir = gateTestHomeDir + return server, store, secretStore, cfg +} + +// TestResolveSurvivingAgentctlAdoptsWhenSurvivalEnabled pins the open side of +// the capability gate: with the capability on, a recorded server that proves +// its identity is adopted, and this launch takes it over by rotating its +// credential rather than starting a second one. +func TestResolveSurvivingAgentctlAdoptsWhenSurvivalEnabled(t *testing.T) { + server, store, secretStore, cfg := newGateFixture(t) + cfg.Features.AgentSurvival = true + + result, _ := resolveSurvivingAgentctl(context.Background(), cfg, newSurvivalTestLogger(t), store, secretStore) + if result == nil { + t.Fatal("result = nil, want the recorded server to be adopted") + } + t.Cleanup(func() { _ = result.cleanup() }) + + rotated, shutDown := server.observed() + if !rotated { + t.Fatal("the adopted server's credential was never rotated") + } + if shutDown { + t.Fatal("the adopted server was shut down") + } + if cfg.Agent.StandaloneAuthToken != "gate-rotated-credential" { + t.Fatalf("StandaloneAuthToken = %q, want the rotated credential", cfg.Agent.StandaloneAuthToken) + } +} + +// TestResolveSurvivingAgentctlReclaimsDetachedServerWhenSurvivalDisabled pins +// the closed side. With the capability off, nothing may be adopted -- but a +// server a previous survival-enabled launch detached is still running with no +// backend attached to it, so this launch stops it instead of leaving it to +// time out on its own, and then falls through to a fresh spawn. +func TestResolveSurvivingAgentctlReclaimsDetachedServerWhenSurvivalDisabled(t *testing.T) { + server, store, secretStore, cfg := newGateFixture(t) + cfg.Features.AgentSurvival = false + + result, _ := resolveSurvivingAgentctl(context.Background(), cfg, newSurvivalTestLogger(t), store, secretStore) + if result != nil { + t.Fatalf("result = %+v, want nil: nothing may be adopted with the capability disabled", result) + } + + rotated, shutDown := server.observed() + if rotated { + t.Fatal("an adoption rotation was attempted with the capability disabled") + } + if !shutDown { + t.Fatal("the detached control server was left running with the capability disabled") + } + if cfg.Agent.StandaloneAuthToken != "" { + t.Fatalf("StandaloneAuthToken = %q, want empty: no server was adopted", cfg.Agent.StandaloneAuthToken) + } +} diff --git a/apps/backend/internal/backendapp/agentctl_survival_test.go b/apps/backend/internal/backendapp/agentctl_survival_test.go new file mode 100644 index 0000000000..354391099e --- /dev/null +++ b/apps/backend/internal/backendapp/agentctl_survival_test.go @@ -0,0 +1,278 @@ +package backendapp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/common/ownershipperiod" + "github.com/kandev/kandev/internal/common/ownershipproof" + "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/task/models" +) + +func newSurvivalTestLogger(t *testing.T) *logger.Logger { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("new logger: %v", err) + } + return log +} + +// fakeControlServerStore is a minimal lifecycle.AdoptionRecordStore double +// scoped to this file's wiring tests. +type fakeControlServerStore struct { + record *models.ControlServerRecord + upserts []*models.ControlServerRecord +} + +func (s *fakeControlServerStore) GetControlServerRecord(context.Context) (*models.ControlServerRecord, error) { + if s.record == nil { + return nil, models.ErrControlServerRecordNotFound + } + return s.record, nil +} + +func (s *fakeControlServerStore) UpsertControlServerRecord(_ context.Context, record *models.ControlServerRecord) error { + s.upserts = append(s.upserts, record) + s.record = record + return nil +} + +// idGeneratingSecretStore is a minimal secrets.SecretStore double that +// auto-generates an ID on Create, scoped to this file's wiring tests +// (mirrors the lifecycle package's own inMemorySecretStore test double). +type idGeneratingSecretStore struct { + mu sync.Mutex + store map[string]string + next int +} + +func newIDGeneratingSecretStore() *idGeneratingSecretStore { + return &idGeneratingSecretStore{store: make(map[string]string)} +} + +func (s *idGeneratingSecretStore) Create(_ context.Context, secret *secrets.SecretWithValue) error { + s.mu.Lock() + defer s.mu.Unlock() + if secret.ID == "" { + s.next++ + secret.ID = fmt.Sprintf("survival-secret-%d", s.next) + } + s.store[secret.ID] = secret.Value + return nil +} + +func (s *idGeneratingSecretStore) Get(_ context.Context, id string) (*secrets.Secret, error) { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.store[id]; !ok { + return nil, fmt.Errorf("%w: %s", secrets.ErrNotFound, id) + } + return &secrets.Secret{ID: id}, nil +} + +func (s *idGeneratingSecretStore) Reveal(_ context.Context, id string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + value, ok := s.store[id] + if !ok { + return "", fmt.Errorf("%w: %s", secrets.ErrNotFound, id) + } + return value, nil +} + +func (s *idGeneratingSecretStore) Update(_ context.Context, id string, req *secrets.UpdateSecretRequest) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.store[id]; !ok { + return fmt.Errorf("%w: %s", secrets.ErrNotFound, id) + } + if req.Value != nil { + s.store[id] = *req.Value + } + return nil +} + +func (s *idGeneratingSecretStore) Delete(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.store, id) + return nil +} + +func (s *idGeneratingSecretStore) List(context.Context) ([]*secrets.SecretListItem, error) { + return nil, nil +} + +func (*idGeneratingSecretStore) Close() error { return nil } + +func TestSplitEndpointParsesHostAndPort(t *testing.T) { + host, port, err := splitEndpoint("127.0.0.1:39429") + if err != nil { + t.Fatalf("splitEndpoint: %v", err) + } + if host != "127.0.0.1" || port != 39429 { + t.Fatalf("host=%q port=%d, want 127.0.0.1:39429", host, port) + } +} + +func TestSplitEndpointRejectsMalformedEndpoint(t *testing.T) { + if _, _, err := splitEndpoint("not-a-valid-endpoint"); err == nil { + t.Fatal("want error for malformed endpoint, got nil") + } +} + +// TestAdoptSurvivingAgentctlReturnsNilWhenNoRecord pins the fallback +// wiring: with no control-server record at all, adoptSurvivingAgentctl must +// return nil so provideAgentctlLauncher falls through to spawning fresh -- +// exactly like the capability being disabled. +func TestAdoptSurvivingAgentctlReturnsNilWhenNoRecord(t *testing.T) { + cfg := &config.Config{} + store := &fakeControlServerStore{} + + result, _ := adoptSurvivingAgentctl(context.Background(), cfg, newSurvivalTestLogger(t), store, nil) + + if result != nil { + t.Fatalf("result = %+v, want nil", result) + } +} + +// TestAdoptSurvivingAgentctlAdoptsAndUpdatesConfig drives the real wiring +// end to end against an httptest server standing in for a surviving +// agentctl: identity, rotate, and confirm are all real HTTP round trips +// through the production agentctl.ControlClient, not a fake. Verifies the +// adopted endpoint/credential land on cfg.Agent.Standalone* and that the +// returned result's cleanup is a no-op (nothing to stop for an adopted +// server per AC-EXECUTORS-SURVIVAL-001.1). +func TestAdoptSurvivingAgentctlAdoptsAndUpdatesConfig(t *testing.T) { + const homeDir = "/home/kandev-test" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/identity": + _ = json.NewEncoder(w).Encode(agentctlclient.IdentityInfo{ + ServerIdentity: "survivor-identity", + Capabilities: lifecycle.RequiredSurvivalCapabilities, + }) + case r.Method == http.MethodPost && r.URL.Path == "/ownership/prove": + var req struct { + Challenge string `json:"challenge"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + _ = json.NewEncoder(w).Encode(map[string][]string{ + "proofs": {ownershipproof.Derive("bootstrap-credential", req.Challenge, homeDir)}, + }) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/ownership/details": + _ = json.NewEncoder(w).Encode(agentctlclient.ServerDetails{ + HomeDir: homeDir, + DiagnosticLogPath: "/home/kandev-test/logs/agentctl-diagnostic.log", + }) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/ownership/rotate": + _ = json.NewEncoder(w).Encode(agentctlclient.CredentialRotationResult{ + RotationID: 1, + Credential: "rotated-credential", + }) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/ownership/confirm": + w.WriteHeader(http.StatusOK) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + secretStore := newIDGeneratingSecretStore() + var seedSecret secrets.SecretWithValue + seedSecret.Name = "seed" + seedSecret.Value = "bootstrap-credential" + if err := secretStore.Create(context.Background(), &seedSecret); err != nil { + t.Fatalf("seed secret: %v", err) + } + + endpoint := server.Listener.Addr().String() + store := &fakeControlServerStore{record: &models.ControlServerRecord{ + Endpoint: endpoint, + ServerIdentity: "survivor-identity", + CredentialSecretID: seedSecret.ID, + }} + cfg := &config.Config{} + cfg.HomeDir = homeDir + + result, _ := adoptSurvivingAgentctl(context.Background(), cfg, newSurvivalTestLogger(t), store, secretStore) + + if result == nil { + t.Fatal("result = nil, want a non-nil adopted result") + } + if err := result.cleanup(); err != nil { + t.Fatalf("cleanup() = %v, want nil (adopted server must not be stopped)", err) + } + if cfg.Agent.StandaloneAuthToken != "rotated-credential" { + t.Fatalf("StandaloneAuthToken = %q, want rotated-credential", cfg.Agent.StandaloneAuthToken) + } + wantHost, wantPort, err := splitEndpoint(endpoint) + if err != nil { + t.Fatalf("splitEndpoint(endpoint): %v", err) + } + if cfg.Agent.StandaloneHost != wantHost || cfg.Agent.StandalonePort != wantPort { + t.Fatalf("StandaloneHost/Port = %s:%d, want %s:%d", cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, wantHost, wantPort) + } + if cfg.Agent.StandalonePID != 0 { + t.Fatalf("StandalonePID = %d, want 0 (this process never spawned the adopted server)", cfg.Agent.StandalonePID) + } + if len(store.upserts) != 1 { + t.Fatalf("upserts = %d, want 1", len(store.upserts)) + } +} + +// TestStartOwnershipRenewalReturnsNilOnMalformedEndpoint pins that a +// malformed endpoint is logged and non-fatal: startup already succeeded by +// the time this is called, so the caller must not fail the whole launch +// over a renewal-loop wiring problem. +func TestStartOwnershipRenewalReturnsNilOnMalformedEndpoint(t *testing.T) { + renewer := startOwnershipRenewal(context.Background(), newSurvivalTestLogger(t), "not-a-valid-endpoint", "cred", time.Minute) + if renewer != nil { + t.Fatal("renewer = non-nil, want nil for a malformed endpoint") + } +} + +// TestStartOwnershipRenewalStartsAndStopsCleanly pins the Start/Stop wiring +// itself (the renewal cadence and retry behaviour are covered by the +// lifecycle package's own OwnershipRenewer tests): a well-formed endpoint +// yields a running renewer, and Stop returns promptly rather than blocking +// for anything resembling the resolved renewal interval. +func TestStartOwnershipRenewalStartsAndStopsCleanly(t *testing.T) { + renewer := startOwnershipRenewal(context.Background(), newSurvivalTestLogger(t), "127.0.0.1:0", "cred", time.Minute) + if renewer == nil { + t.Fatal("renewer = nil, want a started renewer for a well-formed endpoint") + } + renewer.Stop() +} + +// TestResolveAdoptedRenewalPeriodUsesReportedValue pins Review round 3, +// finding 5's fix: an adopted server's own reported unowned period drives +// its renewal cadence, not this launch's local config. +func TestResolveAdoptedRenewalPeriodUsesReportedValue(t *testing.T) { + if got := resolveAdoptedRenewalPeriod(5 * time.Minute); got != 5*time.Minute { + t.Fatalf("resolveAdoptedRenewalPeriod(5m) = %v, want 5m", got) + } +} + +// TestResolveAdoptedRenewalPeriodFallsBackToFloorWhenUnreported pins the +// legacy/pre-upgrade fallback: a server that reported no unowned period at +// all (zero) still gets a sane, non-zero renewal cadence rather than a +// zero-interval renewal loop. +func TestResolveAdoptedRenewalPeriodFallsBackToFloorWhenUnreported(t *testing.T) { + if got := resolveAdoptedRenewalPeriod(0); got != ownershipperiod.MinPeriod { + t.Fatalf("resolveAdoptedRenewalPeriod(0) = %v, want the shared floor %v", got, ownershipperiod.MinPeriod) + } +} diff --git a/apps/backend/internal/backendapp/agents.go b/apps/backend/internal/backendapp/agents.go index 9abafbe673..7e5b40ff3f 100644 --- a/apps/backend/internal/backendapp/agents.go +++ b/apps/backend/internal/backendapp/agents.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "strings" + "time" "go.uber.org/zap" @@ -24,7 +25,6 @@ import ( ) func provideLifecycleManager( - ctx context.Context, cfg *config.Config, log *logger.Logger, eventBus bus.EventBus, @@ -36,6 +36,12 @@ func provideLifecycleManager( managedRuntimeSelections managedruntime.SelectionReader, mcpIdentityScoper lifecycle.MCPIdentityScoper, mcpPrincipalScoper lifecycle.MCPPrincipalScoper, + recoveryDeadlineStart time.Time, + inheritedRecordScope lifecycle.InheritedRecordScope, + workspaceInfoProvider lifecycle.WorkspaceInfoProvider, + passthroughSessionProvider lifecycle.PassthroughSessionProvider, + runningWriter lifecycle.ExecutorRunningWriter, + startupRecoveryGuard *lifecycle.RecoveryGuard, ) (*lifecycle.Manager, error) { log.Info("Initializing Agent Manager...") secretStores := newLifecycleSecretStores(rawSecretStore) @@ -56,6 +62,8 @@ func provideLifecycleManager( cfg.Agent.StandalonePort, log, ) + // Per-instance servers enforce the same single rotating credential as + // the control server -- see the field doc on config.AgentConfig. standaloneExec.SetAuthToken(cfg.Agent.StandaloneAuthToken) // Create InteractiveRunner for passthrough mode (no WorkspaceTracker, uses callbacks) @@ -116,6 +124,36 @@ func provideLifecycleManager( log, ) + // Persistence writer for executors_running, wired before Start runs its + // recovery pass: Start reads ListLiveStandaloneExecutorsRunning to build + // the recovery-inventory correlation guard (AC-EXECUTORS-SURVIVAL-002), and + // that read silently returns no candidates when no writer is set yet. This + // must happen here rather than after provideLifecycleManager returns, or + // every recovery pass sees an empty inventory regardless of what is + // actually durable, misclassifying every live standalone instance as an + // orphan. + lifecycleMgr.SetExecutorRunningWriter(runningWriter) + + // Install the startup recovery guard before the standalone backend gets its + // unstoppable-session sink. Both must refer to the same guard instance, or a + // failed duplicate stop can retain a different guard than the one launch + // admission checks. + lifecycleMgr.SetRecoveryGuard(startupRecoveryGuard) + + // Wire recovery's bounded stop-retry budget (AC-EXECUTORS-SURVIVAL-002.13/ + // 002.15) and the AC-EXECUTORS-SURVIVAL-002.16 unstoppable-session sink + // into the standalone backend before recovery starts. + standaloneExec.SetRecoveryRetryConfig(cfg.Agentctl.RecoveryReadTimeout, cfg.Agentctl.RecoveryReadRetries) + standaloneExec.SetUnstoppableSessionRecorder(lifecycleMgr.RecoveryGuard()) + + // AC-EXECUTORS-SURVIVAL-003.7: the single bound covering this pass's + // adoption+enumeration+reconstruction work, clocked from this launch's + // first contact with a recorded control endpoint (zero when there was + // none -- Start then falls back to its own invocation time). + lifecycleMgr.SetRecoveryDeadline(cfg.Agentctl.RecoveryDeadline) + lifecycleMgr.SetRecoveryDeadlineStart(recoveryDeadlineStart) + lifecycleMgr.SetInheritedRecordScope(inheritedRecordScope) + // Register environment preparers (keyed by ExecutorType — the // "local"/"worktree"/"local_docker"/"sprites" taxonomy, not Runtime). // The Worktree preparer is registered separately in @@ -162,17 +200,47 @@ func provideLifecycleManager( if mcpPrincipalScoper != nil { lifecycleMgr.SetMCPPrincipalScoper(mcpPrincipalScoper) } - - if err := lifecycleMgr.Start(ctx); err != nil { - return nil, err + // AC-EXECUTORS-SURVIVAL-002.14: wire the workspace info provider before + // Start so a recovered execution's task-environment identity is + // reconstructed from the durable store during this same startup pass, + // instead of staying empty until something else happens to resolve it. + if workspaceInfoProvider != nil { + lifecycleMgr.SetWorkspaceInfoProvider(workspaceInfoProvider) } - - log.Info("Agent Manager initialized", + // AC-EXECUTORS-SURVIVAL-005.3: wire the durable passthrough-mode lookup + // before Start so the startup recovery guard can exclude a confirmed + // passthrough session from guarding instead of treating every recovered + // session as guardable. + if passthroughSessionProvider != nil { + lifecycleMgr.SetPassthroughLookup(passthroughLookupFromSessionProvider(passthroughSessionProvider)) + } + // Kill-path #4 (design 01 "Kill paths that must change together"): + // StopAgentWithReason needs the capability state to decide survivable + // detach versus terminating stop on backend shutdown. + lifecycleMgr.SetAgentSurvivalEnabled(cfg.Features.AgentSurvival) + log.Info("Agent Manager configured", zap.Int("runtimes", len(executorRegistry.List())), zap.Int("agent_types", len(agentRegistry.List()))) return lifecycleMgr, nil } +// passthroughLookupFromSessionProvider adapts a durable session reader into +// the lifecycle.PassthroughLookup closure consumed by +// Manager.SetPassthroughLookup (AC-EXECUTORS-SURVIVAL-005.3). A failed or +// empty read answers ok=false so the caller treats the session's mode as +// unknown and falls back to guarding it -- the safe default documented on +// Manager.passthroughLookup: guarding a passthrough session by mistake only +// costs one refused launch, never excluding a real recovery candidate. +func passthroughLookupFromSessionProvider(provider lifecycle.PassthroughSessionProvider) lifecycle.PassthroughLookup { + return func(ctx context.Context, sessionID string) (bool, bool) { + session, err := provider.GetTaskSession(ctx, sessionID) + if err != nil || session == nil { + return false, false + } + return session.IsPassthrough, true + } +} + type lifecycleSecretStores struct { runtime secrets.SecretStore credentials secrets.SecretStore diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index cfc298b0cc..c7b92ecfe6 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -472,7 +472,7 @@ func (s *shutdownDeadlineExecutor) StopInstance( return nil } -func (s *shutdownDeadlineExecutor) RecoverInstances(context.Context) ([]*lifecycle.ExecutorInstance, error) { +func (s *shutdownDeadlineExecutor) RecoverInstances(context.Context, []*models.ExecutorRunning) ([]*lifecycle.ExecutorInstance, error) { return nil, nil } diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 845bc80d67..e9060792c5 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -507,16 +507,31 @@ func startServices( //nolint:cyclop } log.Info("ACP messages will be stored as comments") + // ============================================ + // STARTUP RECOVERY GUARD (AC-EXECUTORS-SURVIVAL-002.8) + // ============================================ + // Read the live standalone recovery-inventory records and take a guard + // for every session they name (except a confirmed passthrough one) + // before contacting any control server -- including the adoption attempt + // provideAgentctlLauncher makes just below. Deferring this until the + // lifecycle manager's own Start() runs would be too late: adoption is + // this backend's first control-server contact, and design 02 "Startup" + // step 3 must precede step 4. + startupRecoveryGuard := lifecycle.TakeStartupRecoveryGuards( + ctx, repos.Task, passthroughLookupFromSessionProvider(services.Task), log) + // ============================================ // AGENTCTL LAUNCHER (for standalone mode) // ============================================ agentRuntimeAvailability := agentctlclient.NewAvailability(eventBus, log) - agentctlResult, err := provideAgentctlLauncher(ctx, cfg, log, agentRuntimeAvailability) + agentctlResult, err := provideAgentctlLauncher(ctx, cfg, log, agentRuntimeAvailability, repos.Task, repos.Secrets) if err != nil { log.Error("Failed to start agentctl subprocess", zap.Error(err)) return false } var agentctlBinaryPath string + var recoveryDeadlineStart time.Time + var inheritedRecordScope lifecycle.InheritedRecordScope if agentctlResult != nil { addCleanup(agentctlResult.cleanup) defer func() { @@ -532,10 +547,13 @@ func startServices( //nolint:cyclop // Capture the binary path so initOfficeServices can include it in the // ServiceOptions when constructing the office service. agentctlBinaryPath = agentctlResult.binaryPath + recoveryDeadlineStart = agentctlResult.recoveryDeadlineStart + inheritedRecordScope = agentctlResult.inheritedRecordScope } return startAgentInfrastructure(ctx, cfg, log, addCleanup, eventBus, agentRuntimeAvailability, - dbPool, repos, services, agentSettingsController, agentRegistry, agentctlBinaryPath, runCleanups, cancelWorkers) + dbPool, repos, services, agentSettingsController, agentRegistry, agentctlBinaryPath, recoveryDeadlineStart, inheritedRecordScope, + startupRecoveryGuard, runCleanups, cancelWorkers) } // startAgentInfrastructure initializes the agent lifecycle manager, worktree, orchestrator, @@ -555,6 +573,9 @@ func startAgentInfrastructure( agentSettingsController *agentsettingscontroller.Controller, agentRegistry *registry.Registry, agentctlBinaryPath string, + recoveryDeadlineStart time.Time, + inheritedRecordScope lifecycle.InheritedRecordScope, + startupRecoveryGuard *lifecycle.RecoveryGuard, runCleanups func(), cancelWorkers context.CancelFunc, ) bool { @@ -585,7 +606,6 @@ func startAgentInfrastructure( // AGENT MANAGER // ============================================ lifecycleMgr, err := provideLifecycleManager( - ctx, cfg, log, eventBus, @@ -597,6 +617,12 @@ func startAgentInfrastructure( services.ManagedRuntimeSelections, mcpScopeResolver.Scope, mcpScopeResolver.ScopePrincipal, + recoveryDeadlineStart, + inheritedRecordScope, + services.Task, + services.Task, + repos.Task, + startupRecoveryGuard, ) if err != nil { log.Error("Failed to initialize agent manager", zap.Error(err)) @@ -626,7 +652,6 @@ func startAgentInfrastructure( services.Task.SetAgentComparisonTargetPusher(lifecycleMgr) services.Task.SetExecutorCapabilityProber(lifecycleMgr) - lifecycleMgr.SetWorkspaceInfoProvider(services.Task) // Session/environment-scoped HTTP surfaces (shell, files, ports, vscode, // LSP, terminals) enforce per-user workspace scoping (opt-in auth). The // GetOrEnsure* execution paths run these checks internally; the vscode and @@ -634,18 +659,11 @@ func startAgentInfrastructure( // the handler, and the SSR terminal-list routes call CheckTaskAccess / // CheckEnvironmentAccess / CheckTaskEnvironmentAccess in a route guard. wireLifecycleAccessCheckers(lifecycleMgr, services.Task) - log.Info("Workspace info provider configured for session recovery") // TODO(task-model-unification Phase 2, ADR 0004): wire agentruntime.New(lifecycleMgr) // once a real consumer (workflow-engine / cron-driven trigger handlers) exists. // Allocating the facade in Phase 1 without a caller is dead code. - // Persistence writer for executors_running. This makes the lifecycle manager - // the sole writer of agent_execution_id / container_id / runtime / status — - // the structural fix for the agent-execution-id divergence bug. Must be set - // before any Launch / EnsureWorkspaceExecutionForSession can run. - lifecycleMgr.SetExecutorRunningWriter(repos.Task) - // Lets user shell terminals export the executor profile's env vars, so the // terminal sees the same variables the agent subprocess and the repository // setup script get. @@ -693,6 +711,9 @@ func startAgentInfrastructure( } services.Task.SetWorkflowMovePreflight(orchestratorSvc) orchestratorSvc.SetAgentctlBinaryPath(agentctlBinaryPath) + // The checker is populated by lifecycleMgr.Start below before the + // orchestrator's startup reconciliation runs. + orchestratorSvc.SetRetrackedSessionChecker(lifecycleMgr.WasSessionRetracked) orchestratorSvc.SetRouteActionHandler(dynamicRouteActionHandler( repos.Task, repos.AgentSettings, @@ -867,6 +888,22 @@ func startAgentInfrastructure( // overlapping that would race the pool swap. databaseQuiesce = addRuntimeCleanup(deliveryCleanup) + // Recovery publishes agent events synchronously. Subscribe the orchestrator + // after all event-handler dependencies are wired, but before lifecycle + // recovery, so a retained terminal outcome cannot be published into an empty + // in-memory bus while Service.Start is still doing its startup reconciliation. + // Service.Start calls Watcher.Start again; the watcher is idempotent and keeps + // these subscriptions. + if err := orchestratorSvc.StartEventWatcher(ctx); err != nil { + log.Error("Failed to start orchestrator event watcher for agent recovery", zap.Error(err)) + return false + } + if err := lifecycleMgr.Start(ctx); err != nil { + _ = orchestratorSvc.StopEventWatcher() + log.Error("Failed to recover agent manager", zap.Error(err)) + return false + } + return startGatewayAndServe(ctx, cfg, log, eventBus, agentRuntimeAvailability, dbPool, repos, services, agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, repoCloner, agentctlBinaryPath, func(fn func() error) { addRuntimeCleanup(fn) }, runCleanups, cancelWorkers, restoreCleanups, databaseQuiesce) @@ -991,6 +1028,8 @@ func startGatewayAndServe( hostControlClient := agentctlclient.NewControlClient(cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, log, agentctlclient.WithControlAuthToken(cfg.Agent.StandaloneAuthToken)) hostUtilityMgr := hostutility.NewManager(agentRegistry, cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, hostControlClient, log) + // Per-instance servers enforce the same single rotating credential as + // the control server -- see config.AgentConfig's field doc. hostUtilityMgr.SetAuthToken(cfg.Agent.StandaloneAuthToken) hostUtilityMgr.SetProfileResolver(profilebinding.New(repos.AgentSettings, func(agentID string) bool { _, ok := agentRegistry.GetInferenceAgent(agentID) diff --git a/apps/backend/internal/backendapp/passthrough_lookup_test.go b/apps/backend/internal/backendapp/passthrough_lookup_test.go new file mode 100644 index 0000000000..c3aeb17e4c --- /dev/null +++ b/apps/backend/internal/backendapp/passthrough_lookup_test.go @@ -0,0 +1,86 @@ +package backendapp + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +type fakePassthroughSessionProvider struct { + session *models.TaskSession + err error +} + +func (f *fakePassthroughSessionProvider) GetTaskSession(_ context.Context, _ string) (*models.TaskSession, error) { + return f.session, f.err +} + +// TestPassthroughLookupFromSessionProviderReturnsSnapshotOnSuccess pins +// Review round 3, finding 6: the closure wired into +// Manager.SetPassthroughLookup must answer with the durable +// TaskSession.IsPassthrough snapshot, not the zero value, when the read +// succeeds. +func TestPassthroughLookupFromSessionProviderReturnsSnapshotOnSuccess(t *testing.T) { + provider := &fakePassthroughSessionProvider{session: &models.TaskSession{IsPassthrough: true}} + lookup := passthroughLookupFromSessionProvider(provider) + + isPassthrough, ok := lookup(context.Background(), "session-1") + + if !ok { + t.Fatal("ok = false, want true on a successful read") + } + if !isPassthrough { + t.Fatal("isPassthrough = false, want true") + } +} + +func TestPassthroughLookupFromSessionProviderReturnsFalseSnapshotOnSuccess(t *testing.T) { + provider := &fakePassthroughSessionProvider{session: &models.TaskSession{IsPassthrough: false}} + lookup := passthroughLookupFromSessionProvider(provider) + + isPassthrough, ok := lookup(context.Background(), "session-1") + + if !ok { + t.Fatal("ok = false, want true on a successful read") + } + if isPassthrough { + t.Fatal("isPassthrough = true, want false") + } +} + +// TestPassthroughLookupFromSessionProviderFailsOpenToUnknownOnError pins the +// safe-default half of finding 6: a failed durable read must answer +// ok=false (unknown), never fabricate isPassthrough=true/false, so the +// recovery-guard caller falls back to guarding the session. +func TestPassthroughLookupFromSessionProviderFailsOpenToUnknownOnError(t *testing.T) { + provider := &fakePassthroughSessionProvider{err: errors.New("db unavailable")} + lookup := passthroughLookupFromSessionProvider(provider) + + isPassthrough, ok := lookup(context.Background(), "session-1") + + if ok { + t.Fatal("ok = true, want false when the durable read fails") + } + if isPassthrough { + t.Fatal("isPassthrough = true, want false alongside ok=false") + } +} + +// TestPassthroughLookupFromSessionProviderFailsOpenToUnknownOnNilSession +// covers a provider answering successfully with a nil session (e.g. scoping +// denial folded into a nil, no-error return by some future implementation). +func TestPassthroughLookupFromSessionProviderFailsOpenToUnknownOnNilSession(t *testing.T) { + provider := &fakePassthroughSessionProvider{session: nil, err: nil} + lookup := passthroughLookupFromSessionProvider(provider) + + isPassthrough, ok := lookup(context.Background(), "session-1") + + if ok { + t.Fatal("ok = true, want false when the provider answers with a nil session") + } + if isPassthrough { + t.Fatal("isPassthrough = true, want false alongside ok=false") + } +} diff --git a/apps/backend/internal/backendapp/session_recovery_guard_wrap_test.go b/apps/backend/internal/backendapp/session_recovery_guard_wrap_test.go new file mode 100644 index 0000000000..5496a3949c --- /dev/null +++ b/apps/backend/internal/backendapp/session_recovery_guard_wrap_test.go @@ -0,0 +1,38 @@ +package backendapp + +import ( + "errors" + "testing" + + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/orchestrator" + "github.com/stretchr/testify/require" +) + +func TestWrapSessionRecoveryGuardErrorTranslatesRetryableSentinel(t *testing.T) { + err := wrapSessionRecoveryGuardError(lifecycle.ErrSessionRecoveryGuarded, "session-1") + + var guardErr *orchestrator.SessionRecoveryGuardError + require.ErrorAs(t, err, &guardErr) + require.True(t, guardErr.Retryable) + require.Equal(t, "session-1", guardErr.SessionID) + require.ErrorIs(t, err, lifecycle.ErrSessionRecoveryGuarded) +} + +func TestWrapSessionRecoveryGuardErrorTranslatesNonRetryableSentinel(t *testing.T) { + err := wrapSessionRecoveryGuardError(lifecycle.ErrSessionUnstoppableAgent, "session-2") + + var guardErr *orchestrator.SessionRecoveryGuardError + require.ErrorAs(t, err, &guardErr) + require.False(t, guardErr.Retryable) + require.Equal(t, "session-2", guardErr.SessionID) + require.ErrorIs(t, err, lifecycle.ErrSessionUnstoppableAgent) +} + +func TestWrapSessionRecoveryGuardErrorPassesThroughUnrelatedError(t *testing.T) { + cause := errors.New("boom") + + err := wrapSessionRecoveryGuardError(cause, "session-3") + + require.Same(t, cause, err) +} diff --git a/apps/backend/internal/common/config/agentctl.go b/apps/backend/internal/common/config/agentctl.go index dcca562067..9a45a1c39a 100644 --- a/apps/backend/internal/common/config/agentctl.go +++ b/apps/backend/internal/common/config/agentctl.go @@ -21,6 +21,20 @@ type AgentctlStartupConfig struct { IdleReaperInterval time.Duration `json:"idleReaperInterval"` NotificationQueueCapacity int `json:"notificationQueueCapacity"` OTLPEndpoint string `json:"otlpEndpoint"` + // UnownedPeriod and DetachedEventLimit are agentctl-owned agent-survival + // tunables. Zero means "not resolved by an agent-survival-aware backend"; + // agentctl falls back to its own built-in defaults in that case (see + // Validate below), the same compatibility posture already used for a + // backend older than this contract. + UnownedPeriod time.Duration `json:"unownedPeriod"` + DetachedEventLimit int `json:"detachedEventLimit"` + // AgentSurvivalEnabled carries the current value of the + // features.agentSurvival runtime flag for this launch. Unlike + // UnownedPeriod/DetachedEventLimit, false is not "unresolved" -- a + // managed launch always sets Configured=true, so false is a meaningful + // "disabled for this launch" answer agentctl must not second-guess with + // its own default. + AgentSurvivalEnabled bool `json:"agentSurvivalEnabled"` } // ManagedAgentctlStartupConfig returns the agentctl settings resolved by the @@ -35,6 +49,9 @@ func (c *Config) ManagedAgentctlStartupConfig() AgentctlStartupConfig { IdleReaperInterval: c.Agentctl.IdleReaperInterval, NotificationQueueCapacity: c.Agentctl.NotificationQueueCapacity, OTLPEndpoint: c.Observability.OTLPEndpoint, + UnownedPeriod: c.Agentctl.UnownedPeriod, + DetachedEventLimit: c.Agentctl.DetachedEventLimit, + AgentSurvivalEnabled: c.Features.AgentSurvival, } } @@ -52,6 +69,15 @@ func (c AgentctlStartupConfig) Validate() error { if c.NotificationQueueCapacity < 1024 || c.NotificationQueueCapacity > 131072 { return fmt.Errorf("agentctl notification queue capacity must be between 1024 and 131072") } + // Zero means "not resolved by an agent-survival-aware caller"; agentctl + // substitutes its own built-in default in that case. Any other value must + // already be within the contract's accepted range. + if c.UnownedPeriod < 0 { + return fmt.Errorf("agentctl unowned period must be zero (unset) or positive") + } + if c.DetachedEventLimit != 0 && (c.DetachedEventLimit < 1 || c.DetachedEventLimit > 10000) { + return fmt.Errorf("agentctl detached event limit must be zero (unset) or between 1 and 10000") + } return nil } diff --git a/apps/backend/internal/common/config/catalog.go b/apps/backend/internal/common/config/catalog.go index 32dd8fb693..a54a146a70 100644 --- a/apps/backend/internal/common/config/catalog.go +++ b/apps/backend/internal/common/config/catalog.go @@ -100,6 +100,11 @@ var startupCatalog = []CatalogEntry{ {Key: "agentctl.idleTimeout", EnvVars: []string{"KANDEV_ACP_IDLE_TIMEOUT"}, Owner: "agentctl", Default: "1h"}, {Key: "agentctl.idleReaperInterval", EnvVars: []string{"KANDEV_ACP_IDLE_REAPER_INTERVAL"}, Owner: "agentctl", Default: "1m"}, {Key: "agentctl.notificationQueueCapacity", EnvVars: []string{"KANDEV_ACP_NOTIF_QUEUE"}, Owner: "agentctl", Default: "131072"}, + {Key: "agentctl.recoveryDeadline", EnvVars: []string{"KANDEV_ACP_RECOVERY_DEADLINE"}, Owner: "backend", Default: "30s"}, + {Key: "agentctl.recoveryReadTimeout", EnvVars: []string{"KANDEV_ACP_RECOVERY_READ_TIMEOUT"}, Owner: "backend", Default: "2s"}, + {Key: "agentctl.recoveryReadRetries", EnvVars: []string{"KANDEV_ACP_RECOVERY_READ_RETRIES"}, Owner: "backend", Default: "2"}, + {Key: "agentctl.unownedPeriod", EnvVars: []string{"KANDEV_ACP_UNOWNED_PERIOD"}, Owner: "agentctl", Default: "10m"}, + {Key: "agentctl.detachedEventLimit", EnvVars: []string{"KANDEV_ACP_DETACHED_EVENT_LIMIT"}, Owner: "agentctl", Default: "100"}, {Key: "planning.coalesceWindowMs", EnvVars: []string{"KANDEV_PLAN_COALESCE_WINDOW_MS"}, Owner: "planning", Default: "300000"}, {Key: "observability.otlpEndpoint", EnvVars: []string{"OTEL_EXPORTER_OTLP_ENDPOINT"}, Owner: "observability", Default: "", Sensitive: true}, {Key: "launcher.webPort", EnvVars: []string{"KANDEV_WEB_PORT"}, Owner: "launcher", Default: "automatic"}, @@ -133,6 +138,7 @@ var startupExclusions = []CatalogExclusion{ {EnvVar: "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF", Class: "debug", Reason: "runtime feature flag registry"}, {EnvVar: "KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING", Class: "debug", Reason: "runtime feature flag registry"}, {EnvVar: "KANDEV_FEATURES_OFFICE_SESSION_IDENTITY", Class: "debug", Reason: "runtime feature flag registry"}, + {EnvVar: "KANDEV_FEATURES_AGENT_SURVIVAL", Class: "profile", Reason: "runtime feature flag registry"}, {EnvVar: "KANDEV_DEBUG_AGENT_MESSAGES", Class: "debug", Reason: "ACP frame diagnostics"}, {EnvVar: "KANDEV_DEBUG_ACP_MAX_FILES", Class: "debug", Reason: "ACP debug retention"}, {EnvVar: "KANDEV_DEBUG_ACP_RETENTION_HOURS", Class: "debug", Reason: "ACP debug retention"}, diff --git a/apps/backend/internal/common/config/catalog_test.go b/apps/backend/internal/common/config/catalog_test.go index e29e4d5a25..e9b7aaaec5 100644 --- a/apps/backend/internal/common/config/catalog_test.go +++ b/apps/backend/internal/common/config/catalog_test.go @@ -123,6 +123,11 @@ func auditedStartupEnvironmentInventory() []auditedStartupEnvironment { {envVar: "KANDEV_ACP_IDLE_TIMEOUT", class: "catalog"}, {envVar: "KANDEV_ACP_IDLE_REAPER_INTERVAL", class: "catalog"}, {envVar: "KANDEV_ACP_NOTIF_QUEUE", class: "catalog"}, + {envVar: "KANDEV_ACP_RECOVERY_DEADLINE", class: "catalog"}, + {envVar: "KANDEV_ACP_RECOVERY_READ_TIMEOUT", class: "catalog"}, + {envVar: "KANDEV_ACP_RECOVERY_READ_RETRIES", class: "catalog"}, + {envVar: "KANDEV_ACP_UNOWNED_PERIOD", class: "catalog"}, + {envVar: "KANDEV_ACP_DETACHED_EVENT_LIMIT", class: "catalog"}, {envVar: "KANDEV_PLAN_COALESCE_WINDOW_MS", class: "catalog"}, {envVar: "OTEL_EXPORTER_OTLP_ENDPOINT", class: "catalog"}, {envVar: "KANDEV_WEB_PORT", class: "catalog"}, @@ -153,6 +158,7 @@ func auditedStartupEnvironmentInventory() []auditedStartupEnvironment { {envVar: "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF", class: "exclusion"}, {envVar: "KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING", class: "exclusion"}, {envVar: "KANDEV_FEATURES_OFFICE_SESSION_IDENTITY", class: "exclusion"}, + {envVar: "KANDEV_FEATURES_AGENT_SURVIVAL", class: "exclusion"}, {envVar: "KANDEV_DEBUG_AGENT_MESSAGES", class: "exclusion"}, {envVar: "KANDEV_DEBUG_ACP_MAX_FILES", class: "exclusion"}, {envVar: "KANDEV_DEBUG_ACP_RETENTION_HOURS", class: "exclusion"}, @@ -230,6 +236,9 @@ func TestAgentctlStartupConfigRoundTripsAndRejectsInvalidValues(t *testing.T) { IdleReaperInterval: 3 * time.Minute, NotificationQueueCapacity: 4096, OTLPEndpoint: "http://collector:4318", + UnownedPeriod: 10 * time.Minute, + DetachedEventLimit: 100, + AgentSurvivalEnabled: true, } raw, err := EncodeAgentctlStartupConfig(want) if err != nil { @@ -248,4 +257,47 @@ func TestAgentctlStartupConfigRoundTripsAndRejectsInvalidValues(t *testing.T) { if _, err := EncodeAgentctlStartupConfig(invalid); err == nil { t.Fatal("EncodeAgentctlStartupConfig accepted an invalid queue capacity") } + + invalidUnownedPeriod := want + invalidUnownedPeriod.UnownedPeriod = -time.Minute + if _, err := EncodeAgentctlStartupConfig(invalidUnownedPeriod); err == nil { + t.Fatal("EncodeAgentctlStartupConfig accepted a negative unowned period") + } + + // Zero is accepted for both fields: an agent-survival-unaware caller (or + // an older backend build) omits them, and agentctl substitutes its own + // built-in default rather than treating an absent value as invalid. + unset := want + unset.UnownedPeriod = 0 + unset.DetachedEventLimit = 0 + if _, err := EncodeAgentctlStartupConfig(unset); err != nil { + t.Fatalf("EncodeAgentctlStartupConfig rejected zero-valued (unset) survival tunables: %v", err) + } + + invalidDetachedEventLimit := want + invalidDetachedEventLimit.DetachedEventLimit = 10001 + if _, err := EncodeAgentctlStartupConfig(invalidDetachedEventLimit); err == nil { + t.Fatal("EncodeAgentctlStartupConfig accepted an out-of-range detached event limit") + } +} + +// TestManagedAgentctlStartupConfigReflectsAgentSurvivalFlag pins that the +// resolved child contract carries the current value of the +// features.agentSurvival runtime flag (Config.Features.AgentSurvival) for +// this launch, mirroring the always-on UnownedPeriod/DetachedEventLimit +// passthrough rather than being gated on either of those being non-zero. +func TestManagedAgentctlStartupConfigReflectsAgentSurvivalFlag(t *testing.T) { + cfg := &Config{} + cfg.Agentctl.NotificationQueueCapacity = 4096 + cfg.Agentctl.IdleReaperInterval = time.Minute + + cfg.Features.AgentSurvival = false + if got := cfg.ManagedAgentctlStartupConfig(); got.AgentSurvivalEnabled { + t.Fatal("AgentSurvivalEnabled = true, want false when the runtime flag is off") + } + + cfg.Features.AgentSurvival = true + if got := cfg.ManagedAgentctlStartupConfig(); !got.AgentSurvivalEnabled { + t.Fatal("AgentSurvivalEnabled = false, want true when the runtime flag is on") + } } diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index 22efc38837..471de923cb 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -83,6 +83,21 @@ type AgentctlConfig struct { IdleTimeout time.Duration `mapstructure:"idleTimeout"` IdleReaperInterval time.Duration `mapstructure:"idleReaperInterval"` NotificationQueueCapacity int `mapstructure:"notificationQueueCapacity"` + + // RecoveryDeadline is the single global bound covering adoption, + // enumeration and reconstruction together during standalone agent + // survival re-tracking at backend startup. + RecoveryDeadline time.Duration `mapstructure:"recoveryDeadline"` + // RecoveryReadTimeout and RecoveryReadRetries bound each read of a + // reconstruction value from the adopted control server during re-tracking. + RecoveryReadTimeout time.Duration `mapstructure:"recoveryReadTimeout"` + RecoveryReadRetries int `mapstructure:"recoveryReadRetries"` + // UnownedPeriod is how long a standalone control server tolerates having + // no owning backend before it stops its instances and exits. + UnownedPeriod time.Duration `mapstructure:"unownedPeriod"` + // DetachedEventLimit bounds the per-instance retained-event count while + // a standalone control server has no owning backend attached. + DetachedEventLimit int `mapstructure:"detachedEventLimit"` } // PlanningConfig contains planning service timing settings. @@ -486,6 +501,13 @@ type FeaturesConfig struct { // kill switch that restores runner-seat binding and task-active-session // decision re-evaluation when disabled. OfficeSessionIdentity bool `mapstructure:"office_session_identity" json:"officeSessionIdentity"` + + // AgentSurvival lets a worktree or local-executor agent session survive a + // backend restart by adopting its still-running standalone control server + // instead of killing it. Off in every embedded profile, and unavailable on + // Windows (survival trades the platform's kill-on-job-close safeguard for + // an adoption handshake, which is untested there). + AgentSurvival bool `mapstructure:"agent_survival" json:"agentSurvival"` } // LoggingConfig holds logging configuration. @@ -536,8 +558,15 @@ type AgentConfig struct { // StandalonePort is the control port for standalone agentctl (default: 39429) StandalonePort int `mapstructure:"standalonePort"` - // StandaloneAuthToken is the per-launch auth token retrieved via handshake. - // Set at runtime after agentctl starts; not persisted in config files. + // StandaloneAuthToken is the single credential that authenticates both + // the agentctl *control server* (instance create/list/delete, health, + // ownership rotate/confirm) and every per-instance agentctl server it + // supervises (/agent/stream, file tree, shell, ...), per design 01 + // "Single driver" (AC-EXECUTORS-CONTROL-OWNERSHIP-002.6). After adopting + // a surviving control server it holds the freshly rotated credential + // (AC-EXECUTORS-CONTROL-OWNERSHIP-002); use it for every client built + // against this control server or any instance it supervises. Set at + // runtime after agentctl starts; not persisted in config files. StandaloneAuthToken string `mapstructure:"-"` // StandalonePID is the OS process id of the standalone agentctl control-server @@ -782,6 +811,9 @@ func loadWithPath(configPath, homeDir string) (*Config, error) { return nil, fmt.Errorf("error unmarshaling config: %w", err) } sources := applyStartupDefaultsAndEnvironment(&cfg, yamlKeys, profileDefaults, envSnapshot) + if err := applySurvivalRecoveryEnv(&cfg, envSnapshot, sources); err != nil { + return nil, fmt.Errorf("config validation failed: %w", err) + } warnings := inspectSecretPermissions(selection, v) cfg.Source = buildConfigSource(selection, v, sources, warnings) diff --git a/apps/backend/internal/common/config/source.go b/apps/backend/internal/common/config/source.go index 169cb2e856..caa33af107 100644 --- a/apps/backend/internal/common/config/source.go +++ b/apps/backend/internal/common/config/source.go @@ -75,6 +75,11 @@ var yamlOnlyStartupKeys = map[string]struct{}{ "agentctl.idleTimeout": {}, "agentctl.idleReaperInterval": {}, "agentctl.notificationQueueCapacity": {}, + "agentctl.recoveryDeadline": {}, + "agentctl.recoveryReadTimeout": {}, + "agentctl.recoveryReadRetries": {}, + "agentctl.unownedPeriod": {}, + "agentctl.detachedEventLimit": {}, "planning.coalesceWindowMs": {}, "office.schedulerTickMs": {}, "observability.otlpEndpoint": {}, @@ -291,6 +296,11 @@ func applyStartupDefaults(cfg *Config, yamlKeys map[string]bool, profileDefaults setDefaultDuration("agentctl.idleTimeout", &cfg.Agentctl.IdleTimeout, time.Hour) setDefaultDuration("agentctl.idleReaperInterval", &cfg.Agentctl.IdleReaperInterval, time.Minute) setDefaultInt("agentctl.notificationQueueCapacity", &cfg.Agentctl.NotificationQueueCapacity, 131072) + setDefaultDuration("agentctl.recoveryDeadline", &cfg.Agentctl.RecoveryDeadline, 30*time.Second) + setDefaultDuration("agentctl.recoveryReadTimeout", &cfg.Agentctl.RecoveryReadTimeout, 2*time.Second) + setDefaultInt("agentctl.recoveryReadRetries", &cfg.Agentctl.RecoveryReadRetries, 2) + setDefaultDuration("agentctl.unownedPeriod", &cfg.Agentctl.UnownedPeriod, 10*time.Minute) + setDefaultInt("agentctl.detachedEventLimit", &cfg.Agentctl.DetachedEventLimit, 100) setDefaultInt("planning.coalesceWindowMs", &cfg.Planning.CoalesceWindowMs, 300000) setDefaultInt("office.schedulerTickMs", &cfg.Office.SchedulerTickMs, 5000) if !yamlKeys["observability.otlpEndpoint"] { @@ -314,6 +324,16 @@ func applyStartupEnvironment(cfg *Config, envSnapshot map[string]string, sources applyNonNegativeDurationEnv("agentctl.idleTimeout", &cfg.Agentctl.IdleTimeout, time.Hour, envSnapshot, sources) applyDurationEnv("agentctl.idleReaperInterval", &cfg.Agentctl.IdleReaperInterval, time.Minute, envSnapshot, sources) applyBoundedIntEnv("agentctl.notificationQueueCapacity", &cfg.Agentctl.NotificationQueueCapacity, 131072, 1024, 131072, envSnapshot, sources) + // UnownedPeriod's ordering-against-idle-timeout clamp and 1-minute floor + // are applied by agentctl itself at control-server start (it is the only + // place both values are simultaneously known); the backend only carries + // a sane positive duration through to the managed-child startup contract. + applyNonNegativeDurationEnv("agentctl.unownedPeriod", &cfg.Agentctl.UnownedPeriod, 10*time.Minute, envSnapshot, sources) + // agentctl.recoveryDeadline, agentctl.recoveryReadTimeout, + // agentctl.recoveryReadRetries and agentctl.detachedEventLimit are + // validated strictly (reject, never clamp) by applySurvivalRecoveryEnv, + // called separately by the caller so an out-of-range value fails startup + // instead of silently substituting the built-in default. applyNonNegativeIntEnv("planning.coalesceWindowMs", &cfg.Planning.CoalesceWindowMs, 300000, envSnapshot, sources) applyPositiveIntEnv("office.schedulerTickMs", &cfg.Office.SchedulerTickMs, 5000, envSnapshot, sources) applyStringEnvAllowEmpty("observability.otlpEndpoint", &cfg.Observability.OTLPEndpoint, envSnapshot, sources) @@ -435,6 +455,68 @@ func applyBoundedIntEnv(key string, target *int, fallback, minimum, maximum int, sources[key] = SourceEnvironment } +// applySurvivalRecoveryEnv validates the four agent-survival tunables whose +// contract requires rejecting an out-of-range value at startup rather than +// silently substituting the built-in default (unlike every other env-sourced +// setting in this file, which falls back to its default on a bad value). +func applySurvivalRecoveryEnv(cfg *Config, env map[string]string, sources map[string]SettingSource) error { + if err := applyStrictDurationEnv("agentctl.recoveryDeadline", &cfg.Agentctl.RecoveryDeadline, time.Second, 5*time.Minute, env, sources); err != nil { + return err + } + if err := applyStrictDurationEnv("agentctl.recoveryReadTimeout", &cfg.Agentctl.RecoveryReadTimeout, 100*time.Millisecond, time.Hour, env, sources); err != nil { + return err + } + if err := applyStrictIntEnv("agentctl.recoveryReadRetries", &cfg.Agentctl.RecoveryReadRetries, 0, 10, env, sources); err != nil { + return err + } + if err := applyStrictIntEnv("agentctl.detachedEventLimit", &cfg.Agentctl.DetachedEventLimit, 1, 10000, env, sources); err != nil { + return err + } + return nil +} + +func applyStrictDurationEnv(key string, target *time.Duration, minimum, maximum time.Duration, env map[string]string, sources map[string]SettingSource) error { + entry, ok := CatalogEntryForKey(key) + if !ok { + return nil + } + raw, found := nonEmptyEnv(env, entry.EnvVars...) + if !found { + return nil + } + parsed, err := time.ParseDuration(strings.TrimSpace(raw)) + if err != nil { + return fmt.Errorf("%s: invalid duration %q from %s", key, raw, entry.EnvVars[0]) + } + if parsed < minimum || parsed > maximum { + return fmt.Errorf("%s: %s is outside the accepted range [%s, %s]", key, parsed, minimum, maximum) + } + *target = parsed + sources[key] = SourceEnvironment + return nil +} + +func applyStrictIntEnv(key string, target *int, minimum, maximum int, env map[string]string, sources map[string]SettingSource) error { + entry, ok := CatalogEntryForKey(key) + if !ok { + return nil + } + raw, found := nonEmptyEnv(env, entry.EnvVars...) + if !found { + return nil + } + parsed, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return fmt.Errorf("%s: invalid integer %q from %s", key, raw, entry.EnvVars[0]) + } + if parsed < minimum || parsed > maximum { + return fmt.Errorf("%s: %d is outside the accepted range [%d, %d]", key, parsed, minimum, maximum) + } + *target = parsed + sources[key] = SourceEnvironment + return nil +} + func applyBoolEnv(key string, target *bool, fallback bool, env map[string]string, sources map[string]SettingSource) { entry, ok := CatalogEntryForKey(key) if !ok { diff --git a/apps/backend/internal/common/config/survival_recovery_config_test.go b/apps/backend/internal/common/config/survival_recovery_config_test.go new file mode 100644 index 0000000000..77f4208698 --- /dev/null +++ b/apps/backend/internal/common/config/survival_recovery_config_test.go @@ -0,0 +1,156 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestSurvivalRecoveryConfigDefaults(t *testing.T) { + cfg, err := LoadWithPath(t.TempDir()) + if err != nil { + t.Fatalf("LoadWithPath: %v", err) + } + if cfg.Agentctl.RecoveryDeadline != 30*time.Second { + t.Errorf("RecoveryDeadline = %v, want 30s", cfg.Agentctl.RecoveryDeadline) + } + if cfg.Agentctl.RecoveryReadTimeout != 2*time.Second { + t.Errorf("RecoveryReadTimeout = %v, want 2s", cfg.Agentctl.RecoveryReadTimeout) + } + if cfg.Agentctl.RecoveryReadRetries != 2 { + t.Errorf("RecoveryReadRetries = %d, want 2", cfg.Agentctl.RecoveryReadRetries) + } + if cfg.Agentctl.UnownedPeriod != 10*time.Minute { + t.Errorf("UnownedPeriod = %v, want 10m", cfg.Agentctl.UnownedPeriod) + } + if cfg.Agentctl.DetachedEventLimit != 100 { + t.Errorf("DetachedEventLimit = %d, want 100", cfg.Agentctl.DetachedEventLimit) + } +} + +func TestSurvivalRecoveryConfigYAMLRoundTrips(t *testing.T) { + dir := t.TempDir() + contents := `agentctl: + recoveryDeadline: 45s + recoveryReadTimeout: 500ms + recoveryReadRetries: 3 + unownedPeriod: 15m + detachedEventLimit: 250 +` + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(contents), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + cfg, err := LoadWithPath(dir) + if err != nil { + t.Fatalf("LoadWithPath: %v", err) + } + if cfg.Agentctl.RecoveryDeadline != 45*time.Second { + t.Errorf("RecoveryDeadline = %v, want 45s", cfg.Agentctl.RecoveryDeadline) + } + if cfg.Agentctl.RecoveryReadTimeout != 500*time.Millisecond { + t.Errorf("RecoveryReadTimeout = %v, want 500ms", cfg.Agentctl.RecoveryReadTimeout) + } + if cfg.Agentctl.RecoveryReadRetries != 3 { + t.Errorf("RecoveryReadRetries = %d, want 3", cfg.Agentctl.RecoveryReadRetries) + } + if cfg.Agentctl.UnownedPeriod != 15*time.Minute { + t.Errorf("UnownedPeriod = %v, want 15m", cfg.Agentctl.UnownedPeriod) + } + if cfg.Agentctl.DetachedEventLimit != 250 { + t.Errorf("DetachedEventLimit = %d, want 250", cfg.Agentctl.DetachedEventLimit) + } +} + +func TestSurvivalRecoveryConfigEnvOverridesYAML(t *testing.T) { + dir := t.TempDir() + contents := "agentctl:\n recoveryDeadline: 45s\n" + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(contents), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + t.Setenv("KANDEV_ACP_RECOVERY_DEADLINE", "1m") + cfg, err := LoadWithPath(dir) + if err != nil { + t.Fatalf("LoadWithPath: %v", err) + } + if cfg.Agentctl.RecoveryDeadline != time.Minute { + t.Errorf("RecoveryDeadline = %v, want 1m (env should win over YAML)", cfg.Agentctl.RecoveryDeadline) + } +} + +func TestSurvivalRecoveryConfigRejectsOutOfRangeEnv(t *testing.T) { + cases := []struct { + name string + envVar string + value string + }{ + {"recoveryDeadline too low", "KANDEV_ACP_RECOVERY_DEADLINE", "500ms"}, + {"recoveryDeadline too high", "KANDEV_ACP_RECOVERY_DEADLINE", "10m"}, + {"recoveryReadTimeout too low", "KANDEV_ACP_RECOVERY_READ_TIMEOUT", "50ms"}, + {"recoveryReadRetries negative", "KANDEV_ACP_RECOVERY_READ_RETRIES", "-1"}, + {"recoveryReadRetries too high", "KANDEV_ACP_RECOVERY_READ_RETRIES", "11"}, + {"detachedEventLimit zero", "KANDEV_ACP_DETACHED_EVENT_LIMIT", "0"}, + {"detachedEventLimit too high", "KANDEV_ACP_DETACHED_EVENT_LIMIT", "10001"}, + {"recoveryDeadline malformed", "KANDEV_ACP_RECOVERY_DEADLINE", "not-a-duration"}, + {"recoveryReadRetries malformed", "KANDEV_ACP_RECOVERY_READ_RETRIES", "not-an-int"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(tc.envVar, tc.value) + if _, err := LoadWithPath(t.TempDir()); err == nil { + t.Fatalf("LoadWithPath: expected rejection for %s=%q, got no error (value must be rejected, not clamped to default)", tc.envVar, tc.value) + } + }) + } +} + +func TestSurvivalRecoveryConfigRejectsOutOfRangeYAML(t *testing.T) { + dir := t.TempDir() + contents := "agentctl:\n recoveryDeadline: 10m\n" + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(contents), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + if _, err := LoadWithPath(dir); err == nil { + t.Fatal("LoadWithPath: expected rejection for out-of-range YAML recoveryDeadline, got no error") + } +} + +func TestSurvivalRecoveryConfigRejectsReadBudgetOverCeiling(t *testing.T) { + dir := t.TempDir() + // timeout(4s) * (retries(2)+1) = 12s exceeds min(6s, deadline(30s)/5=6s) = 6s. + contents := "agentctl:\n recoveryReadTimeout: 4s\n recoveryReadRetries: 2\n" + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(contents), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + if _, err := LoadWithPath(dir); err == nil { + t.Fatal("LoadWithPath: expected rejection when read-timeout*(retries+1) exceeds the ceiling, got no error") + } +} + +func TestSurvivalRecoveryConfigReadBudgetValidatedAgainstConfiguredDeadline(t *testing.T) { + dir := t.TempDir() + // timeout(2s) * (retries(2)+1) = 6s. Against the default 30s deadline this + // is within the 6s ceiling, but against a configured 10s deadline the + // ceiling drops to min(6s, 2s) = 2s, so it must be rejected. + contents := "agentctl:\n recoveryDeadline: 10s\n recoveryReadTimeout: 2s\n recoveryReadRetries: 2\n" + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(contents), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + if _, err := LoadWithPath(dir); err == nil { + t.Fatal("LoadWithPath: expected rejection validated against the actually-configured recoveryDeadline, got no error") + } +} + +func TestSurvivalRecoveryConfigCatalogEntriesPresent(t *testing.T) { + for _, key := range []string{ + "agentctl.recoveryDeadline", + "agentctl.recoveryReadTimeout", + "agentctl.recoveryReadRetries", + "agentctl.unownedPeriod", + "agentctl.detachedEventLimit", + } { + if _, ok := CatalogEntryForKey(key); !ok { + t.Errorf("catalog is missing %q", key) + } + } +} diff --git a/apps/backend/internal/common/config/validation.go b/apps/backend/internal/common/config/validation.go index df12c405e9..e4fdc8b4db 100644 --- a/apps/backend/internal/common/config/validation.go +++ b/apps/backend/internal/common/config/validation.go @@ -4,6 +4,7 @@ import ( "fmt" "net" "strings" + "time" ) func validateStartupSettings(cfg *Config) error { @@ -16,6 +17,11 @@ func validateStartupSettings(cfg *Config) error { errs = append(errs, configuredValidation(cfg, "agentctl.idleTimeout", cfg.Agentctl.IdleTimeout < 0, "agentctl.idleTimeout must be zero or greater")...) errs = append(errs, configuredValidation(cfg, "agentctl.idleReaperInterval", cfg.Agentctl.IdleReaperInterval <= 0, "agentctl.idleReaperInterval must be positive")...) errs = append(errs, configuredValidation(cfg, "agentctl.notificationQueueCapacity", cfg.Agentctl.NotificationQueueCapacity < 1024 || cfg.Agentctl.NotificationQueueCapacity > 131072, "agentctl.notificationQueueCapacity must be between 1024 and 131072")...) + errs = append(errs, configuredValidation(cfg, "agentctl.recoveryDeadline", cfg.Agentctl.RecoveryDeadline < time.Second || cfg.Agentctl.RecoveryDeadline > 5*time.Minute, "agentctl.recoveryDeadline must be between 1s and 5m")...) + errs = append(errs, configuredValidation(cfg, "agentctl.recoveryReadTimeout", cfg.Agentctl.RecoveryReadTimeout < 100*time.Millisecond, "agentctl.recoveryReadTimeout must be at least 100ms")...) + errs = append(errs, configuredValidation(cfg, "agentctl.recoveryReadRetries", cfg.Agentctl.RecoveryReadRetries < 0 || cfg.Agentctl.RecoveryReadRetries > 10, "agentctl.recoveryReadRetries must be between 0 and 10")...) + errs = append(errs, configuredValidation(cfg, "agentctl.detachedEventLimit", cfg.Agentctl.DetachedEventLimit < 1 || cfg.Agentctl.DetachedEventLimit > 10000, "agentctl.detachedEventLimit must be between 1 and 10000")...) + errs = append(errs, validateRecoveryReadBudget(cfg)...) errs = append(errs, configuredValidation(cfg, "planning.coalesceWindowMs", cfg.Planning.CoalesceWindowMs < 0, "planning.coalesceWindowMs must be zero or greater")...) errs = append(errs, configuredValidation(cfg, "office.schedulerTickMs", cfg.Office.SchedulerTickMs <= 0, "office.schedulerTickMs must be positive")...) errs = append(errs, configuredValidation(cfg, "launcher.webPort", cfg.Launcher.WebPort < 1 || cfg.Launcher.WebPort > 65535, "launcher.webPort must be between 1 and 65535")...) @@ -26,6 +32,29 @@ func validateStartupSettings(cfg *Config) error { return nil } +// validateRecoveryReadBudget rejects a configured combination of +// agentctl.recoveryReadTimeout and agentctl.recoveryReadRetries whose total +// attempt duration exceeds the lesser of 6 seconds or one fifth of the +// actually-configured agentctl.recoveryDeadline. Unlike configuredValidation, +// this check is unconditional: the three values it compares can each come +// from a different source (YAML, environment, or built-in default), and the +// contract requires validating the combination regardless of where any one +// of them came from. +func validateRecoveryReadBudget(cfg *Config) []string { + attempts := time.Duration(cfg.Agentctl.RecoveryReadRetries+1) * cfg.Agentctl.RecoveryReadTimeout + ceiling := cfg.Agentctl.RecoveryDeadline / 5 + if 6*time.Second < ceiling { + ceiling = 6 * time.Second + } + if attempts > ceiling { + return []string{fmt.Sprintf( + "agentctl.recoveryReadTimeout * (agentctl.recoveryReadRetries + 1) = %s exceeds the allowed ceiling of %s (the lesser of 6s and one fifth of agentctl.recoveryDeadline = %s)", + attempts, ceiling, cfg.Agentctl.RecoveryDeadline, + )} + } + return nil +} + func configuredValidation(cfg *Config, key string, invalid bool, message string) []string { if cfg.SourceFor(key) == SourceConfiguration && invalid { return []string{message} diff --git a/apps/backend/internal/common/netprobe/port.go b/apps/backend/internal/common/netprobe/port.go new file mode 100644 index 0000000000..0ae4013058 --- /dev/null +++ b/apps/backend/internal/common/netprobe/port.go @@ -0,0 +1,59 @@ +// Package netprobe holds the single contract for deciding whether a local +// TCP port is occupied. +// +// It lives in internal/common because the two callers sit on opposite sides +// of a dependency boundary -- the process launcher that picks the backend, +// web, and agentctl ports, and the runtime-tier launcher that starts a +// standalone agentctl -- and a port answered "free" by one and "busy" by the +// other is how a backend ends up talking to a control server it did not +// start. +package netprobe + +import ( + "fmt" + "net" + "strconv" + "time" +) + +// ConnectProbeTimeout bounds each loopback connect probe so a silently +// dropped SYN (for example under WSL2 mirrored networking to an unbound +// loopback port) cannot hang port selection. A timed-out connect counts as +// "nothing listening". +const ConnectProbeTimeout = 500 * time.Millisecond + +// PortAvailable reports whether a port is free. It is free only when a +// dual-stack loopback connect finds nothing listening AND a fresh loopback +// bind succeeds. +// +// The connect probe is required because a server binds the wildcard address +// (0.0.0.0 and [::]): on macOS/BSD a specific 127.0.0.1 bind succeeds against +// an active wildcard listener (Go also sets SO_REUSEADDR), so a bind-only +// check reports a busy port as free. The bind probe is retained because it +// catches reservations a connect misses (Windows phantom reservations, +// TIME_WAIT). +func PortAvailable(port int) bool { + if HasListener(port) { + return false + } + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return false + } + _ = ln.Close() + return true +} + +// HasListener reports whether anything answers a loopback connect on either +// the IPv4 or IPv6 loopback address. A server may hold only the IPv6 wildcard +// socket, so both families are probed. +func HasListener(port int) bool { + for _, host := range []string{"127.0.0.1", "::1"} { + conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), ConnectProbeTimeout) + if err == nil { + _ = conn.Close() + return true + } + } + return false +} diff --git a/apps/backend/internal/common/netprobe/port_test.go b/apps/backend/internal/common/netprobe/port_test.go new file mode 100644 index 0000000000..a34797cb74 --- /dev/null +++ b/apps/backend/internal/common/netprobe/port_test.go @@ -0,0 +1,83 @@ +package netprobe + +import ( + "net" + "testing" +) + +// TestPortAvailableReportsAWildcardListenerAsOccupied is the case a +// bind-only check gets wrong. A control server binds the wildcard address, +// and on macOS/BSD a fresh 127.0.0.1 bind against an active wildcard +// listener succeeds, so a launcher that only tries to bind concludes the +// port is free and starts a second server on a port some durable record +// already names. +func TestPortAvailableReportsAWildcardListenerAsOccupied(t *testing.T) { + listener, err := net.Listen("tcp", ":0") + if err != nil { + t.Fatalf("wildcard listen: %v", err) + } + defer func() { _ = listener.Close() }() + port := listener.Addr().(*net.TCPAddr).Port + + if PortAvailable(port) { + t.Fatalf("port %d reported free while a wildcard listener holds it", port) + } + if !HasListener(port) { + t.Fatalf("port %d reported as having no listener while one is bound", port) + } +} + +// TestPortAvailableReportsALoopbackListenerAsOccupied covers the narrower +// bind: a server listening only on IPv4 loopback is still occupying the +// port for our purposes. +func TestPortAvailableReportsALoopbackListenerAsOccupied(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("loopback listen: %v", err) + } + defer func() { _ = listener.Close() }() + port := listener.Addr().(*net.TCPAddr).Port + + if PortAvailable(port) { + t.Fatalf("port %d reported free while a loopback listener holds it", port) + } +} + +// TestPortAvailableReportsAClosedPortAsFree is the open side: the probe must +// not be so conservative that it never finds a usable port. +func TestPortAvailableReportsAClosedPortAsFree(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("loopback listen: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + if err := listener.Close(); err != nil { + t.Fatalf("close listener: %v", err) + } + + if !PortAvailable(port) { + t.Fatalf("port %d reported occupied after its listener closed", port) + } + if HasListener(port) { + t.Fatalf("port %d reported a listener after its listener closed", port) + } +} + +// TestHasListenerProbesBothLoopbackFamilies pins the dual-stack half of the +// contract: a server holding only the IPv6 wildcard socket is still found. +// Skipped where the host has no usable IPv6 loopback. +func TestHasListenerProbesBothLoopbackFamilies(t *testing.T) { + listener, err := net.Listen("tcp6", "[::1]:0") + if err != nil { + t.Skipf("no usable IPv6 loopback on this host: %v", err) + } + defer func() { _ = listener.Close() }() + port := listener.Addr().(*net.TCPAddr).Port + + if !HasListener(port) { + t.Fatalf("port %d reported as having no listener while an IPv6 listener holds it", port) + } + if PortAvailable(port) { + t.Fatalf("port %d reported free while an IPv6 listener holds it", port) + } +} diff --git a/apps/backend/internal/common/ownershipperiod/period.go b/apps/backend/internal/common/ownershipperiod/period.go new file mode 100644 index 0000000000..7b6d2b5c74 --- /dev/null +++ b/apps/backend/internal/common/ownershipperiod/period.go @@ -0,0 +1,56 @@ +// Package ownershipperiod resolves the effective control-server unowned +// period from its two independent inputs. It is cross-tier shared code +// (both the agentctl control server, which enforces the period, and the +// backend, which must renew ownership strictly inside a third of it, need +// the identical computation) and therefore lives in internal/common rather +// than being owned by either side and imported across the boundary. +package ownershipperiod + +import "time" + +// MinPeriod is the floor of AC-EXECUTORS-CONTROL-OWNERSHIP-003.7: no +// configuration may produce an unowned period at or near zero, which would +// expire ownership before an adopting backend could ever claim it. +const MinPeriod = time.Minute + +// Resolve applies AC-EXECUTORS-CONTROL-OWNERSHIP-003.4/.6/.7 to a +// configured unowned period, given the (possibly disabled, i.e. <= 0) +// per-instance idle timeout. It returns the effective period and a +// human-readable adjustment reason for each rule that fired, so the caller +// can log what happened (both criteria require recording the adjustment). +// +// Order matters: the idle-timeout clamp (003.4) is applied first, then the +// one-minute floor (003.7) is applied second and takes precedence -- when +// the idle timeout is short enough that no value satisfies both, the floor +// wins and the ordering constraint is left unsatisfied on purpose (003.7). +func Resolve(configured, idleTimeout time.Duration) (period time.Duration, adjustments []string) { + period = configured + + // 003.6: a disabled idle timeout (<=0) leaves nothing for the ordering + // constraint to order, so the clamp of 003.4 is skipped entirely rather + // than derived from the disabled sentinel. + if idleTimeout > 0 && period >= idleTimeout { + period = idleTimeout / 2 + adjustments = append(adjustments, + "unowned period was not shorter than the idle timeout; clamped to half the idle timeout") + } + + if period < MinPeriod { + period = MinPeriod + adjustments = append(adjustments, + "unowned period was below the one-minute floor; raised to the floor") + } + + return period, adjustments +} + +// RenewalInterval returns the cadence AC-EXECUTORS-CONTROL-OWNERSHIP-003.2 +// requires a renewing backend to use for a given resolved unowned period: +// strictly shorter than one third of it, so that after two consecutive +// failed renewals a third attempt still falls strictly inside the period. +// Quartering rather than using period/3 leaves headroom against scheduling +// jitter and network latency instead of relying on integer-division +// rounding to satisfy "strictly shorter". +func RenewalInterval(period time.Duration) time.Duration { + return period / 4 +} diff --git a/apps/backend/internal/common/ownershipperiod/period_test.go b/apps/backend/internal/common/ownershipperiod/period_test.go new file mode 100644 index 0000000000..0de9982301 --- /dev/null +++ b/apps/backend/internal/common/ownershipperiod/period_test.go @@ -0,0 +1,98 @@ +package ownershipperiod + +import ( + "testing" + "time" +) + +func TestResolve(t *testing.T) { + tests := []struct { + name string + configured time.Duration + idleTimeout time.Duration + wantPeriod time.Duration + wantAdjustments int + }{ + { + name: "already satisfies ordering, no adjustment", + configured: 5 * time.Minute, + idleTimeout: 10 * time.Minute, + wantPeriod: 5 * time.Minute, + wantAdjustments: 0, + }, + { + name: "AC-003.4 clamps to half the idle timeout when not shorter", + configured: 10 * time.Minute, + idleTimeout: 10 * time.Minute, + wantPeriod: 5 * time.Minute, + wantAdjustments: 1, + }, + { + name: "AC-003.4 clamps when configured exceeds idle timeout", + configured: 20 * time.Minute, + idleTimeout: 4 * time.Minute, + wantPeriod: 2 * time.Minute, + wantAdjustments: 1, + }, + { + name: "AC-003.6 disabled idle reaping (zero) is left unchanged", + configured: 10 * time.Minute, + idleTimeout: 0, + wantPeriod: 10 * time.Minute, + wantAdjustments: 0, + }, + { + name: "AC-003.6 negative idle timeout treated as disabled", + configured: 10 * time.Minute, + idleTimeout: -1, + wantPeriod: 10 * time.Minute, + wantAdjustments: 0, + }, + { + name: "AC-003.7 floor applies when configured value is too small", + configured: 10 * time.Second, + idleTimeout: 0, + wantPeriod: MinPeriod, + wantAdjustments: 1, + }, + { + name: "AC-003.7 floor takes precedence over the AC-003.4 clamp", + configured: time.Minute, + idleTimeout: 30 * time.Second, + wantPeriod: MinPeriod, + wantAdjustments: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + period, adjustments := Resolve(tt.configured, tt.idleTimeout) + if period != tt.wantPeriod { + t.Fatalf("Resolve(%v, %v) period = %v, want %v", + tt.configured, tt.idleTimeout, period, tt.wantPeriod) + } + if len(adjustments) != tt.wantAdjustments { + t.Fatalf("Resolve(%v, %v) adjustments = %v (len %d), want len %d", + tt.configured, tt.idleTimeout, adjustments, len(adjustments), tt.wantAdjustments) + } + }) + } +} + +// TestRenewalInterval pins AC-EXECUTORS-CONTROL-OWNERSHIP-003.2: three +// consecutive intervals must sum to strictly less than the period, so a +// third renewal attempt after two failures is still due strictly before the +// unowned-period expiry test can fire. +func TestRenewalInterval(t *testing.T) { + periods := []time.Duration{MinPeriod, 90 * time.Second, 5 * time.Minute, 10 * time.Minute, time.Hour} + for _, period := range periods { + interval := RenewalInterval(period) + if interval <= 0 { + t.Fatalf("RenewalInterval(%v) = %v, want > 0", period, interval) + } + if threeIntervals := 3 * interval; threeIntervals >= period { + t.Fatalf("RenewalInterval(%v) = %v; 3x = %v, want strictly less than the period", + period, interval, threeIntervals) + } + } +} diff --git a/apps/backend/internal/common/ownershipproof/proof.go b/apps/backend/internal/common/ownershipproof/proof.go new file mode 100644 index 0000000000..7432c73fad --- /dev/null +++ b/apps/backend/internal/common/ownershipproof/proof.go @@ -0,0 +1,44 @@ +// Package ownershipproof derives the value a control server returns to +// demonstrate it already holds an ownership credential, without disclosing +// it. Both tiers need the identical derivation -- agentctl produces it and +// the backend verifies it -- and agentctl ships as its own binary, so this +// lives in common rather than in either side. +package ownershipproof + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" +) + +// Derive returns the proof for credential over challenge. binding is mixed +// in after a zero separator so a server belonging to a different +// installation cannot answer for this one, and so the challenge and the +// binding cannot run together into an ambiguous input. +func Derive(credential, challenge, binding string) string { + mac := hmac.New(sha256.New, []byte(credential)) + mac.Write([]byte(challenge)) + mac.Write([]byte{0}) + mac.Write([]byte(binding)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// Matches reports whether any offered proof is the one credential produces +// for this challenge and binding. A server may hold either of two +// acceptable credentials while a rotation is unconfirmed, so it offers one +// proof per credential and the verifier accepts on any match. Comparison is +// constant-time. An empty credential, an empty challenge, or no offered +// proofs never match: a caller with nothing to verify has proved nothing. +func Matches(credential, challenge, binding string, offered []string) bool { + if credential == "" || challenge == "" { + return false + } + want := []byte(Derive(credential, challenge, binding)) + matched := false + for _, candidate := range offered { + if hmac.Equal([]byte(candidate), want) { + matched = true + } + } + return matched +} diff --git a/apps/backend/internal/common/ownershipproof/proof_test.go b/apps/backend/internal/common/ownershipproof/proof_test.go new file mode 100644 index 0000000000..7aed35c6bd --- /dev/null +++ b/apps/backend/internal/common/ownershipproof/proof_test.go @@ -0,0 +1,49 @@ +package ownershipproof + +import "testing" + +func TestDeriveDependsOnEveryInput(t *testing.T) { + base := Derive("cred", "chal", "bind") + for _, tc := range []struct{ name, cred, chal, bind string }{ + {"credential", "other", "chal", "bind"}, + {"challenge", "cred", "other", "bind"}, + {"binding", "cred", "chal", "other"}, + } { + if Derive(tc.cred, tc.chal, tc.bind) == base { + t.Errorf("proof did not vary with the %s", tc.name) + } + } + if base == "cred" { + t.Fatal("proof is the credential verbatim") + } + // The separator must keep adjacent fields from running together, so + // these two distinct (challenge, binding) pairs cannot collide. + if Derive("cred", "ab", "c") == Derive("cred", "a", "bc") { + t.Fatal("challenge and binding are not unambiguously separated") + } +} + +func TestMatchesAcceptsAnyOfferedProofAndRefusesNothing(t *testing.T) { + chal, bind := "chal", "bind" + mine := Derive("mine", chal, bind) + theirs := Derive("theirs", chal, bind) + + if !Matches("mine", chal, bind, []string{theirs, mine}) { + t.Error("did not accept a matching proof offered alongside another") + } + if Matches("mine", chal, bind, []string{theirs}) { + t.Error("accepted a proof derived from a different credential") + } + if Matches("mine", chal, bind, nil) { + t.Error("accepted an empty set of proofs") + } + if Matches("mine", chal, bind, []string{""}) { + t.Error("accepted an empty proof") + } + if Matches("", chal, bind, []string{Derive("", chal, bind)}) { + t.Error("accepted a proof when no credential was held") + } + if Matches("mine", "", bind, []string{Derive("mine", "", bind)}) { + t.Error("accepted a proof over an empty challenge") + } +} diff --git a/apps/backend/internal/launcher/ports.go b/apps/backend/internal/launcher/ports.go index 577efc7508..0a92f84421 100644 --- a/apps/backend/internal/launcher/ports.go +++ b/apps/backend/internal/launcher/ports.go @@ -3,12 +3,12 @@ package launcher import ( "fmt" "math/rand" - "net" "os" "strconv" "time" "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/netprobe" ) type portConfig struct { @@ -217,41 +217,9 @@ func pickAvailablePortExcept(preferred int, used map[int]bool) (int, error) { return 0, fmt.Errorf("unable to find a free port") } -// connectProbeTimeout bounds each loopback connect probe so a silently dropped -// SYN (for example under WSL2 mirrored networking to an unbound loopback port) -// cannot hang port selection. A timed-out connect counts as "nothing listening". -const connectProbeTimeout = 500 * time.Millisecond - -// canBind reports whether a port is available. It is available only when a -// dual-stack loopback connect finds nothing listening AND a fresh loopback bind -// succeeds. The connect probe is required because a running backend binds the -// wildcard address (0.0.0.0 and [::]): on macOS/BSD a specific 127.0.0.1 bind -// succeeds against an active wildcard listener (Go also sets SO_REUSEADDR), so a -// bind-only check would falsely report the busy port as free. The bind probe is -// retained because it catches reservations a connect misses (Windows phantom -// reservations, TIME_WAIT). +// canBind reports whether a port is available, using the shared probe +// contract in internal/common/netprobe so the launcher and the agentctl +// launcher can never disagree about whether a port is occupied. func canBind(port int) bool { - if portHasListener(port) { - return false - } - ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) - if err != nil { - return false - } - _ = ln.Close() - return true -} - -// portHasListener reports whether anything answers a loopback connect on either -// the IPv4 or IPv6 loopback address. The existing backend may hold only the IPv6 -// wildcard socket, so both families are probed. -func portHasListener(port int) bool { - for _, host := range []string{"127.0.0.1", "::1"} { - conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), connectProbeTimeout) - if err == nil { - _ = conn.Close() - return true - } - } - return false + return netprobe.PortAvailable(port) } diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index 37ecc8dab8..042dffe549 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -287,19 +287,23 @@ type mockAgentManager struct { // optional rowLivenessProber so reconciliation tests can drive runtime-aware // liveness per row. Nil → the mock is not a prober and reconciliation treats // every row as Unknown. - rowLivenessFn func(*models.ExecutorRunning) models.ProcessLiveness - resolveProfileInfo *executor.AgentProfileInfo - resolveProfileErr error - restartProcessCalls []string // tracks execution IDs passed to RestartAgentProcess - restartProcessErr error - promptErr error - promptResult *executor.PromptResult - promptAcceptedOnError bool - promptAgentFunc func(context.Context, string, string, []v1.MessageAttachment, bool) (*executor.PromptResult, error) - launchAgentFunc func(context.Context, *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) - startAgentProcessCalls []string - startAgentProcessErr error - startAgentProcessFunc func(context.Context, string) error + rowLivenessFn func(*models.ExecutorRunning) models.ProcessLiveness + // newStandaloneLivenessScopeCalls counts NewStandaloneLivenessScope + // invocations, letting a test assert a reconciliation pass takes exactly + // one enumeration and reuses it across every row. + newStandaloneLivenessScopeCalls int + resolveProfileInfo *executor.AgentProfileInfo + resolveProfileErr error + restartProcessCalls []string // tracks execution IDs passed to RestartAgentProcess + restartProcessErr error + promptErr error + promptResult *executor.PromptResult + promptAcceptedOnError bool + promptAgentFunc func(context.Context, string, string, []v1.MessageAttachment, bool) (*executor.PromptResult, error) + launchAgentFunc func(context.Context, *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) + startAgentProcessCalls []string + startAgentProcessErr error + startAgentProcessFunc func(context.Context, string) error // probeBackgroundWorkloadsFunc, when non-nil, overrides // ProbeBackgroundWorkloads's default Unknown/nil response. @@ -668,6 +672,21 @@ func (m *mockAgentManager) RowLiveness(row *models.ExecutorRunning) models.Proce } return models.ProcessLivenessUnknown } + +// NewStandaloneLivenessScope and RowLivenessScoped make the mock satisfy the +// orchestrator's optional standaloneLivenessScoper alongside rowLivenessProber. +// The mock has no real enumeration to scope, so it returns a nil placeholder +// scope and RowLivenessScoped simply delegates to the existing RowLiveness/ +// rowLivenessFn machinery -- every existing rowLivenessFn-based test keeps its +// exact behavior without change. +func (m *mockAgentManager) NewStandaloneLivenessScope(_ context.Context) interface{} { + m.newStandaloneLivenessScopeCalls++ + return nil +} + +func (m *mockAgentManager) RowLivenessScoped(row *models.ExecutorRunning, _ interface{}) models.ProcessLiveness { + return m.RowLiveness(row) +} func (m *mockAgentManager) ResolveAgentProfile(_ context.Context, _ string) (*executor.AgentProfileInfo, error) { if m.resolveProfileErr != nil { return nil, m.resolveProfileErr diff --git a/apps/backend/internal/orchestrator/executor/executor_host_gh_bridge_test.go b/apps/backend/internal/orchestrator/executor/executor_host_gh_bridge_test.go index 3be846a837..5f9ccd0773 100644 --- a/apps/backend/internal/orchestrator/executor/executor_host_gh_bridge_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_host_gh_bridge_test.go @@ -454,8 +454,9 @@ func TestExecutorHostGHBridgeEligibility(t *testing.T) { } hostsMu.Lock() defer hostsMu.Unlock() - if strings.Join(hosts, ",") != "github.com,ghe.example" { - t.Fatalf("probed hosts = %v, want [github.com ghe.example]", hosts) + sort.Strings(hosts) + if strings.Join(hosts, ",") != "ghe.example,github.com" { + t.Fatalf("probed hosts = %v, want [ghe.example github.com]", hosts) } }) diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index c9a7a86336..095858bbae 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -138,10 +138,13 @@ func (h *Handlers) wsLaunchSession(ctx context.Context, msg *ws.Message) (*ws.Me resp, err := h.service.LaunchSession(ctx, &req) if err != nil { - intent := orchestrator.ResolveIntent(&req) - if recoveryResponse, responseErr := taskArchivedConflictResponse(msg, err); recoveryResponse != nil || responseErr != nil { - return recoveryResponse, responseErr + if guardResponse, responseErr := sessionRecoveryGuardConflictResponse(msg, err); guardResponse != nil || responseErr != nil { + return guardResponse, responseErr } + if archivedResponse, responseErr := taskArchivedConflictResponse(msg, err); archivedResponse != nil || responseErr != nil { + return archivedResponse, responseErr + } + intent := orchestrator.ResolveIntent(&req) // A launch failing because the root context was cancelled or the // session is already terminal is an expected shutdown teardown race, // not a fault: log WARN (no stack trace) so it does not masquerade as a @@ -266,6 +269,23 @@ func branchRecoveryConflictResponse(msg *ws.Message, err error) (*ws.Message, er return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeConflict, err.Error(), branchRecoveryErr.Details()) } +// sessionRecoveryGuardConflictResponse surfaces the startup recovery guard's +// two distinct refusal reasons: a retryable in-progress recovery maps to +// CONFLICT (the caller should retry once recovery resolves), and a +// non-retryable unstoppable-agent condition maps to UNAVAILABLE (only a +// backend restart clears it). +func sessionRecoveryGuardConflictResponse(msg *ws.Message, err error) (*ws.Message, error) { + var guardErr *orchestrator.SessionRecoveryGuardError + if !errors.As(err, &guardErr) { + return nil, nil + } + code := ws.ErrorCodeUnavailable + if guardErr.Retryable { + code = ws.ErrorCodeConflict + } + return ws.NewError(msg.ID, msg.Action, code, err.Error(), guardErr.Details()) +} + func taskArchivedConflictResponse(msg *ws.Message, err error) (*ws.Message, error) { if !errors.Is(err, executor.ErrTaskArchived) { return nil, nil @@ -311,6 +331,9 @@ func (h *Handlers) wsRecoverSession(ctx context.Context, msg *ws.Message) (*ws.M if recoveryResponse, responseErr := branchRecoveryConflictResponse(msg, err); recoveryResponse != nil || responseErr != nil { return recoveryResponse, responseErr } + if guardResponse, responseErr := sessionRecoveryGuardConflictResponse(msg, err); guardResponse != nil || responseErr != nil { + return guardResponse, responseErr + } h.logger.Error("failed to recover session", zap.String("task_id", req.TaskID), zap.String("session_id", req.SessionID), diff --git a/apps/backend/internal/orchestrator/handlers/handlers_test.go b/apps/backend/internal/orchestrator/handlers/handlers_test.go index 6d63b4a23f..b7238af906 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers_test.go +++ b/apps/backend/internal/orchestrator/handlers/handlers_test.go @@ -59,6 +59,51 @@ func TestBranchRecoveryConflictResponsePreservesRecoveryDetails(t *testing.T) { require.Equal(t, "resume_new_branch", payload.Details["recovery_action"]) } +func TestSessionRecoveryGuardConflictResponseMapsRetryableToConflict(t *testing.T) { + msg := createTestMessage(t, ws.ActionSessionLaunch, map[string]interface{}{}) + err := &orchestrator.SessionRecoveryGuardError{ + Cause: errors.New("session is guarded pending recovery"), + SessionID: "session-1", + Retryable: true, + } + + response, responseErr := sessionRecoveryGuardConflictResponse(msg, err) + require.NoError(t, responseErr) + require.NotNil(t, response) + payload := parseError(t, response) + require.Equal(t, ws.ErrorCodeConflict, payload.Code) + require.Equal(t, "session_recovery_in_progress", payload.Details["kind"]) + require.Equal(t, true, payload.Details["retryable"]) + require.Equal(t, "session-1", payload.Details["session_id"]) +} + +func TestSessionRecoveryGuardConflictResponseMapsNonRetryableToUnavailable(t *testing.T) { + msg := createTestMessage(t, ws.ActionSessionLaunch, map[string]interface{}{}) + err := &orchestrator.SessionRecoveryGuardError{ + Cause: errors.New("session has an unstoppable agent from a prior launch"), + SessionID: "session-2", + Retryable: false, + } + + response, responseErr := sessionRecoveryGuardConflictResponse(msg, err) + require.NoError(t, responseErr) + require.NotNil(t, response) + payload := parseError(t, response) + require.Equal(t, ws.ErrorCodeUnavailable, payload.Code) + require.Equal(t, "session_recovery_unstoppable", payload.Details["kind"]) + require.Equal(t, false, payload.Details["retryable"]) + require.Equal(t, "session-2", payload.Details["session_id"]) +} + +func TestSessionRecoveryGuardConflictResponseIgnoresUnrelatedError(t *testing.T) { + msg := createTestMessage(t, ws.ActionSessionLaunch, map[string]interface{}{}) + + response, responseErr := sessionRecoveryGuardConflictResponse(msg, errors.New("boom")) + + require.NoError(t, responseErr) + require.Nil(t, response) +} + func TestWsEnsureSessionRequestParsesAutoStartOverride(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/orchestrator/reconcile_liveness.go b/apps/backend/internal/orchestrator/reconcile_liveness.go index bd79dc0098..816b3d63ad 100644 --- a/apps/backend/internal/orchestrator/reconcile_liveness.go +++ b/apps/backend/internal/orchestrator/reconcile_liveness.go @@ -25,6 +25,22 @@ type rowLivenessProber interface { RowLiveness(row *models.ExecutorRunning) models.ProcessLiveness } +// standaloneLivenessScoper is the optional capability the orchestrator uses to +// take one adopted-server enumeration and reuse it across every row of a +// single reconciliation pass (design 02 "Persistence": "it has two kinds of +// caller, and only one of them is a pass"). Satisfied by the lifecycle +// adapter alongside rowLivenessProber. A caller outside a pass (e.g. the idle +// reclaim path) must keep using rowLivenessProber's context-free RowLiveness, +// which always takes its own fresh enumeration. +// +// Kept as its own narrow optional interface rather than widening +// rowLivenessProber's RowLiveness method, so the 17 existing test doubles +// built around that single-row, context-free signature don't have to change. +type standaloneLivenessScoper interface { + NewStandaloneLivenessScope(ctx context.Context) interface{} + RowLivenessScoped(row *models.ExecutorRunning, scope interface{}) models.ProcessLiveness +} + // agentRunningProber preserves runtime probe errors so reclaim can fail // closed. The legacy boolean method remains the fallback for test doubles and // adapters that do not expose the richer probe yet. @@ -152,6 +168,32 @@ func (s *Service) rowLiveness(row *models.ExecutorRunning) models.ProcessLivenes return prober.RowLiveness(row) } +// newStandaloneLivenessScope takes one adopted-server enumeration for reuse +// across every row of a single reconciliation pass, when s.agentManager +// implements standaloneLivenessScoper. Returns nil otherwise (unit tests, +// degraded startup, or a caller that doesn't support scoped enumeration); +// rowLivenessScoped treats a nil scope as "fall back to the unscoped probe". +func (s *Service) newStandaloneLivenessScope(ctx context.Context) interface{} { + scoper, ok := s.agentManager.(standaloneLivenessScoper) + if !ok || scoper == nil { + return nil + } + return scoper.NewStandaloneLivenessScope(ctx) +} + +// rowLivenessScoped classifies row's liveness reusing scope (from +// newStandaloneLivenessScope) when s.agentManager supports it, falling back +// to the ordinary context-free rowLiveness otherwise -- so a caller can +// unconditionally pass whatever newStandaloneLivenessScope returned, even a +// nil one from a prober that doesn't implement the scoped capability. +func (s *Service) rowLivenessScoped(row *models.ExecutorRunning, scope interface{}) models.ProcessLiveness { + scoper, ok := s.agentManager.(standaloneLivenessScoper) + if !ok || scoper == nil { + return s.rowLiveness(row) + } + return scoper.RowLivenessScoped(row, scope) +} + // pruneOrRepairExecutorRow enforces the resume-safety deletion invariant // (#1597 resume-safety invariant) at a reconciliation cleanup site: a row backing // a resumable session, or holding a resume_token, is repaired in place (never diff --git a/apps/backend/internal/orchestrator/reconcile_liveness_scope_test.go b/apps/backend/internal/orchestrator/reconcile_liveness_scope_test.go new file mode 100644 index 0000000000..8b0c78d24f --- /dev/null +++ b/apps/backend/internal/orchestrator/reconcile_liveness_scope_test.go @@ -0,0 +1,84 @@ +package orchestrator + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/agentruntime" + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/task/models" +) + +// TestReconcileExecutorSessionsOnStartupReusesOneLivenessScopePerPass pins +// design 02 "Persistence": startup reconciliation is a pass, so it must take +// exactly one adopted-server enumeration and reuse it across every row, +// never enumerate once per row. +func TestReconcileExecutorSessionsOnStartupReusesOneLivenessScopePerPass(t *testing.T) { + repo := setupTestRepo(t) + ctx := context.Background() + + for _, id := range []string{"1", "2"} { + taskID := "task-scope-" + id + sessionID := "session-scope-" + id + seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateIdle) + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: sessionID, + SessionID: sessionID, + TaskID: taskID, + Runtime: agentruntime.RuntimeStandalone, + Status: models.ExecutorRunningStatusRunning, + LocalPID: 4242, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("upsert executor row %s: %v", sessionID, err) + } + } + + agentMgr := &mockAgentManager{} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + + svc.reconcileExecutorSessionsOnStartup(ctx) + + if agentMgr.newStandaloneLivenessScopeCalls != 1 { + t.Fatalf("NewStandaloneLivenessScope calls = %d, want exactly 1 for a two-row reconciliation pass", + agentMgr.newStandaloneLivenessScopeCalls) + } +} + +// TestReconcileExecutorSessionsOnStartupSkipsScopeWithoutStandaloneCapability +// pins the fallback: when s.agentManager doesn't implement +// standaloneLivenessScoper, reconciliation still runs (via the ordinary +// unscoped rowLiveness) without panicking on a nil scope. +func TestReconcileExecutorSessionsOnStartupSkipsScopeWithoutStandaloneCapability(t *testing.T) { + repo := setupTestRepo(t) + ctx := context.Background() + seedTaskAndSession(t, repo, "task-noscope", "session-noscope", models.TaskSessionStateIdle) + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "session-noscope", + SessionID: "session-noscope", + TaskID: "task-noscope", + Runtime: agentruntime.RuntimeStandalone, + Status: models.ExecutorRunningStatusRunning, + LocalPID: 4242, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("upsert executor row: %v", err) + } + + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), nonScopingAgentManager{&mockAgentManager{}}) + + svc.reconcileExecutorSessionsOnStartup(ctx) +} + +// nonScopingAgentManager embeds executor.AgentManagerClient by interface +// value, not by *mockAgentManager, so only the methods that interface +// declares are promoted -- NewStandaloneLivenessScope/RowLivenessScoped are +// not part of it, so nonScopingAgentManager does not satisfy +// standaloneLivenessScoper even though the wrapped *mockAgentManager has +// those methods. +type nonScopingAgentManager struct { + executor.AgentManagerClient +} diff --git a/apps/backend/internal/orchestrator/reconcile_retracked_sessions_test.go b/apps/backend/internal/orchestrator/reconcile_retracked_sessions_test.go new file mode 100644 index 0000000000..d37a044e5f --- /dev/null +++ b/apps/backend/internal/orchestrator/reconcile_retracked_sessions_test.go @@ -0,0 +1,207 @@ +package orchestrator + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// countingTurnService is a minimal TurnService stub that only needs to +// record AbandonOpenTurns calls; every other method panics if a test +// exercises a path that was not supposed to reach it. +type countingTurnService struct { + mu sync.Mutex + abandonOpenTurnsCall int +} + +func (c *countingTurnService) AbandonOpenTurns(context.Context, string) error { + c.mu.Lock() + c.abandonOpenTurnsCall++ + c.mu.Unlock() + return nil +} +func (c *countingTurnService) calls() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.abandonOpenTurnsCall +} +func (*countingTurnService) StartTurn(context.Context, string) (*models.Turn, error) { + panic("countingTurnService: StartTurn should not be called") +} +func (*countingTurnService) ReserveTurn(context.Context, string, *models.PromptDispatchRecovery) (*models.Turn, error) { + panic("countingTurnService: ReserveTurn should not be called") +} +func (*countingTurnService) MarkReservedTurnDispatchAttempted(context.Context, *models.Turn) error { + panic("countingTurnService: MarkReservedTurnDispatchAttempted should not be called") +} +func (*countingTurnService) PublishReservedTurn(context.Context, *models.Turn) error { + panic("countingTurnService: PublishReservedTurn should not be called") +} +func (*countingTurnService) RollbackReservedTurn(context.Context, string, string) (bool, error) { + panic("countingTurnService: RollbackReservedTurn should not be called") +} +func (*countingTurnService) ReconcileUnpublishedPromptTurns(context.Context) (int, error) { + return 0, nil +} +func (*countingTurnService) CompleteTurn(context.Context, string) error { return nil } +func (*countingTurnService) GetTurn(context.Context, string) (*models.Turn, error) { + return nil, nil +} +func (*countingTurnService) GetActiveTurn(context.Context, string) (*models.Turn, error) { + return nil, nil +} +func (*countingTurnService) UpdateTurn(context.Context, *models.Turn) error { return nil } +func (*countingTurnService) PatchTurnMetadata(context.Context, string, string, map[string]interface{}) error { + return nil +} + +// TestReconcileSessionsOnStartupSkipsReconciliationWhenRetracked pins +// AC-EXECUTORS-SURVIVAL-003.2/.3/.4/.5: a session the lifecycle manager +// reports as re-tracked keeps its RUNNING state, its task stays IN_PROGRESS, +// its task is never marked interrupted, and its open turns are never +// abandoned -- because its agent actually survived the restart, none of +// today's "backend died mid-turn" cleanup applies to it. +func TestReconcileSessionsOnStartupSkipsReconciliationWhenRetracked(t *testing.T) { + repo := setupTestRepo(t) + ctx := context.Background() + now := time.Now().UTC() + + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateRunning) + + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "er1", + SessionID: "session1", + TaskID: "task1", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("failed to upsert executor running: %v", err) + } + + taskRepo := newMockTaskRepo() + taskRepo.tasks["task1"] = &v1.Task{ID: "task1", State: v1.TaskStateInProgress} + + turns := &countingTurnService{} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, &mockAgentManager{}) + svc.turnService = turns + svc.SetRetrackedSessionChecker(func(sessionID string) bool { return sessionID == "session1" }) + + svc.reconcileSessionsOnStartup(ctx) + + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("failed to load session: %v", err) + } + if session.State != models.TaskSessionStateRunning { + t.Fatalf("session state = %q, want %q (AC-EXECUTORS-SURVIVAL-003.3)", session.State, models.TaskSessionStateRunning) + } + + if state, ok := taskRepo.updatedStates["task1"]; ok { + t.Fatalf("expected task state left untouched (AC-EXECUTORS-SURVIVAL-003.5), got write to %q", state) + } + + task, err := repo.GetTask(ctx, "task1") + if err != nil { + t.Fatalf("failed to load task: %v", err) + } + if _, marked := task.Metadata[models.MetaKeyInterruptedAt]; marked { + t.Fatal("expected task NOT to be marked interrupted (AC-EXECUTORS-SURVIVAL-003.2)") + } + + if got := turns.calls(); got != 0 { + t.Fatalf("AbandonOpenTurns called %d times, want 0 (AC-EXECUTORS-SURVIVAL-003.4)", got) + } +} + +// TestReconcileSessionsOnStartupAppliesFullReconciliationWhenNotRetracked is +// the regression pin for the same fixture as above, but with the checker +// reporting the session as NOT re-tracked -- today's full "backend died +// mid-turn" reconciliation must still apply exactly as before Layer 6.6. +func TestReconcileSessionsOnStartupAppliesFullReconciliationWhenNotRetracked(t *testing.T) { + repo := setupTestRepo(t) + ctx := context.Background() + now := time.Now().UTC() + + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateRunning) + + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "er1", + SessionID: "session1", + TaskID: "task1", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("failed to upsert executor running: %v", err) + } + + taskRepo := newMockTaskRepo() + taskRepo.tasks["task1"] = &v1.Task{ID: "task1", State: v1.TaskStateInProgress} + + turns := &countingTurnService{} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, &mockAgentManager{}) + svc.turnService = turns + svc.SetRetrackedSessionChecker(func(sessionID string) bool { return false }) + + svc.reconcileSessionsOnStartup(ctx) + + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("failed to load session: %v", err) + } + if session.State != models.TaskSessionStateWaitingForInput { + t.Fatalf("session state = %q, want %q", session.State, models.TaskSessionStateWaitingForInput) + } + + if state, ok := taskRepo.updatedStates["task1"]; !ok || state != v1.TaskStateReview { + t.Fatalf("expected task moved to REVIEW, updatedStates[task1] = %q (ok=%v)", state, ok) + } + + task, err := repo.GetTask(ctx, "task1") + if err != nil { + t.Fatalf("failed to load task: %v", err) + } + if _, marked := task.Metadata[models.MetaKeyInterruptedAt]; !marked { + t.Fatal("expected task to be marked interrupted") + } + + if got := turns.calls(); got != 1 { + t.Fatalf("AbandonOpenTurns called %d times, want 1", got) + } +} + +// TestReconcileSessionsOnStartupUnwiredCheckerTreatsEverySessionAsNotRetracked +// pins the nil-checker default: when SetRetrackedSessionChecker is never +// called (agent survival disabled, or backendapp wiring absent), every +// active session is reconciled exactly as before Layer 6.6. +func TestReconcileSessionsOnStartupUnwiredCheckerTreatsEverySessionAsNotRetracked(t *testing.T) { + repo := setupTestRepo(t) + ctx := context.Background() + now := time.Now().UTC() + + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateRunning) + + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "er1", + SessionID: "session1", + TaskID: "task1", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("failed to upsert executor running: %v", err) + } + + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{}) + svc.reconcileSessionsOnStartup(ctx) + + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("failed to load session: %v", err) + } + if session.State != models.TaskSessionStateWaitingForInput { + t.Fatalf("session state = %q, want %q", session.State, models.TaskSessionStateWaitingForInput) + } +} diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 119f970aee..c1c10e2321 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -744,6 +744,16 @@ type Service struct { // session.ensure). Nil = unscoped. taskAccessCheck func(ctx context.Context, taskID string) error + // retrackedSessionCheck reports whether the lifecycle manager + // successfully re-tracked a session during this backend's own startup + // recovery pass (AC-EXECUTORS-SURVIVAL-003.1). Consulted by startup + // reconciliation so a session whose agent survived the restart skips + // "backend died mid-turn" cleanup. Nil (unwired, or agent survival + // disabled) preserves today's behavior -- every active session is + // reconciled as if its agent died with the backend. See + // SetRetrackedSessionChecker. + retrackedSessionCheck func(sessionID string) bool + // routeActionHandler is owned by the dynamic conductor composition. The // orchestrator only validates/authorizes the request and returns the // authoritative route snapshot; concrete and dynamic callers share this @@ -2017,6 +2027,27 @@ func (s *Service) SetTaskAccessChecker(check func(ctx context.Context, taskID st s.taskAccessCheck = check } +// SetRetrackedSessionChecker installs the lifecycle manager's query for +// AC-EXECUTORS-SURVIVAL-003.1: whether a session was successfully +// re-tracked during this backend's own startup recovery pass. Wired from +// backendapp after the lifecycle manager's synchronous Start() has already +// completed, so every session's outcome is decided before this service's +// own Start() runs startup reconciliation. Nil (unwired) preserves today's +// behavior. +func (s *Service) SetRetrackedSessionChecker(check func(sessionID string) bool) { + s.retrackedSessionCheck = check +} + +// wasSessionRetracked applies the configured retrackedSessionCheck. Nil +// checker or empty sessionID is treated as "not retracked" -- today's +// unconditional reconciliation, the correct default. +func (s *Service) wasSessionRetracked(sessionID string) bool { + if s.retrackedSessionCheck == nil || sessionID == "" { + return false + } + return s.retrackedSessionCheck(sessionID) +} + // authorizeSession applies the configured per-user session check. No-op when // unwired. func (s *Service) authorizeSession(ctx context.Context, sessionID string) error { @@ -3129,6 +3160,27 @@ func (s *Service) Start(ctx context.Context) error { return nil } +// StartEventWatcher subscribes the orchestrator before lifecycle recovery. +// Recovery can publish an agent event before Service.Start completes its +// startup reconciliation, so those subscriptions must exist first. +// Watcher.Start is idempotent; Service.Start can call it again at its normal +// startup point without replacing these subscriptions. +func (s *Service) StartEventWatcher(ctx context.Context) error { + if s.watcher == nil { + return errors.New("orchestrator event watcher is not configured") + } + return s.watcher.Start(ctx) +} + +// StopEventWatcher removes subscriptions created by StartEventWatcher when a +// later startup phase fails before Service.Start owns the watcher lifecycle. +func (s *Service) StopEventWatcher() error { + if s.watcher == nil { + return nil + } + return s.watcher.Stop() +} + // Stop stops all orchestrator components func (s *Service) Stop() error { s.mu.Lock() @@ -3342,6 +3394,10 @@ func (s *Service) reconcileExecutorSessionsOnStartup(ctx context.Context) { s.logger.Info("reconciling sessions on startup (lazy recovery)", zap.Int("count", len(runningExecutors))) report := newStartupCleanupReport() + // One adopted-server enumeration, reused for every row of this pass + // (design 02 "Persistence": "it has two kinds of caller, and only one of + // them is a pass") -- taken once here rather than per-row. + scope := s.newStandaloneLivenessScope(ctx) var remoteRecords []executor.RemoteStatusPollRequest for _, running := range runningExecutors { if models.IsRemoteExecutorType(models.ExecutorType(running.Runtime)) { @@ -3354,7 +3410,7 @@ func (s *Service) reconcileExecutorSessionsOnStartup(ctx context.Context) { Metadata: running.Metadata, }) } - s.reconcileOneSessionOnStartup(ctx, running, report) + s.reconcileOneSessionOnStartup(ctx, running, report, scope) } report.flush(s.logger) @@ -3365,7 +3421,7 @@ func (s *Service) reconcileExecutorSessionsOnStartup(ctx context.Context) { } // reconcileOneSessionOnStartup adjusts DB state for a single session without launching agents. -func (s *Service) reconcileOneSessionOnStartup(ctx context.Context, running *models.ExecutorRunning, report *startupCleanupReport) { +func (s *Service) reconcileOneSessionOnStartup(ctx context.Context, running *models.ExecutorRunning, report *startupCleanupReport, scope interface{}) { sessionID := running.SessionID if sessionID == "" { return @@ -3374,7 +3430,7 @@ func (s *Service) reconcileOneSessionOnStartup(ctx context.Context, running *mod session, err := s.repo.GetTaskSession(ctx, sessionID) if err != nil { if isTaskSessionNotFound(err) { - s.handleMissingSessionOnStartup(ctx, running, report) + s.handleMissingSessionOnStartup(ctx, running, report, scope) return } s.logger.Warn("failed to load session for reconciliation; preserving executor record", @@ -3386,7 +3442,7 @@ func (s *Service) reconcileOneSessionOnStartup(ctx context.Context, running *mod previousState := session.State // Handle terminal and never-started states (reuse existing cleanup logic) - if skip := s.handleTerminalSessionOnStartup(ctx, session, running, previousState, report); skip { + if skip := s.handleTerminalSessionOnStartup(ctx, session, running, previousState, report, scope); skip { return } if previousState == models.TaskSessionStateCreated { @@ -3413,7 +3469,7 @@ func (s *Service) reconcileOneSessionOnStartup(ctx context.Context, running *mod // dead local_pid. If the local process is confirmed dead, repair the row // in place — resume_token/worktree are preserved (RowMustBePreserved // treats IDLE as resumable). Remote rows report Unknown and are untouched. - if s.rowLiveness(running) == models.ProcessLivenessDead { + if s.rowLivenessScoped(running, scope) == models.ProcessLivenessDead { s.repairDeadRowLiveness(ctx, running) } s.logger.Info("session reconciled for lazy recovery (idle, no state change)", @@ -3424,7 +3480,7 @@ func (s *Service) reconcileOneSessionOnStartup(ctx context.Context, running *mod return } - s.reconcileActiveSessionOnStartup(ctx, running, sessionID, previousState, session) + s.reconcileActiveSessionOnStartup(ctx, running, sessionID, previousState, session, scope) } func (s *Service) reconcileActiveSessionOnStartup( @@ -3433,10 +3489,23 @@ func (s *Service) reconcileActiveSessionOnStartup( sessionID string, previousState models.TaskSessionState, session *models.TaskSession, + scope interface{}, ) { + // AC-EXECUTORS-SURVIVAL-003.1: the lifecycle manager's re-tracking + // outcome for sessionID is already decided by the time this runs (its + // Start() is synchronous and completes before this service's own Start() + // is ever called). A re-tracked session's agent survived the restart and + // is already running under this backend, so none of the "backend died + // mid-turn" reconciliation below applies to it + // (AC-EXECUTORS-SURVIVAL-003.2/.3/.4/.5). + retracked := s.wasSessionRetracked(sessionID) + // Active states: STARTING, RUNNING, WAITING_FOR_INPUT - // Set session to WAITING_FOR_INPUT (idle, ready for lazy resume when user opens it) - if previousState != models.TaskSessionStateWaitingForInput { + // Set session to WAITING_FOR_INPUT (idle, ready for lazy resume when user + // opens it) -- unless the session was re-tracked, in which case it is + // still genuinely running and AC-EXECUTORS-SURVIVAL-003.3 requires + // leaving it in that state. + if !retracked && previousState != models.TaskSessionStateWaitingForInput { s.updateTaskSessionStateWithHook( ctx, running.TaskID, @@ -3448,7 +3517,11 @@ func (s *Service) reconcileActiveSessionOnStartup( session, ) } - s.abandonOpenTurnsOnStartup(ctx, sessionID, "active session reconciled to waiting") + // AC-EXECUTORS-SURVIVAL-003.4: a re-tracked session's open turn is still + // live, not abandoned. + if !retracked { + s.abandonOpenTurnsOnStartup(ctx, sessionID, "active session reconciled to waiting") + } // PRESERVE executors_running.agent_execution_id post-restart. The in-memory // process is gone, but the stored ID still serves as a "this session was @@ -3461,8 +3534,9 @@ func (s *Service) reconcileActiveSessionOnStartup( // in-memory Add on the next launch, closing the divergence window we set out // to fix in this refactor. - // Ensure task is in REVIEW state (not stuck IN_PROGRESS) - if running.TaskID != "" { + // Ensure task is in REVIEW state (not stuck IN_PROGRESS) -- skipped for a + // re-tracked session's task, per AC-EXECUTORS-SURVIVAL-003.5. + if !retracked && running.TaskID != "" { task, taskErr := s.repo.GetTask(ctx, running.TaskID) if taskErr == nil && task != nil && task.State == v1.TaskStateInProgress && !taskArchived(task) { // UpdateTaskStateIfCurrentIn (not the unconditional UpdateTaskState) @@ -3481,11 +3555,14 @@ func (s *Service) reconcileActiveSessionOnStartup( // Mark the task interrupted when its session was mid-turn when the backend // died (STARTING/RUNNING) so task-list surfaces can show the red // interruption icon. WAITING_FOR_INPUT sessions were idle, not interrupted. + // A re-tracked session's task was never actually interrupted -- its agent + // kept running across the restart -- so this is skipped for it per + // AC-EXECUTORS-SURVIVAL-003.2. // The write is archive-atomic (SetTaskMetadataKeyIfNotArchived), so an // archive that commits after this check cannot leave a stale marker on an // archived task. The marker is cleared when a session of the task next // enters STARTING/RUNNING (see updateTaskSessionStateWithHook). - if running.TaskID != "" && (previousState == models.TaskSessionStateStarting || previousState == models.TaskSessionStateRunning) { + if !retracked && running.TaskID != "" && (previousState == models.TaskSessionStateStarting || previousState == models.TaskSessionStateRunning) { if _, setErr := s.repo.SetTaskMetadataKeyIfNotArchived(ctx, running.TaskID, models.MetaKeyInterruptedAt, time.Now().UTC().Format(time.RFC3339)); setErr != nil { s.logger.Warn("failed to mark task interrupted on startup", zap.String("task_id", running.TaskID), @@ -3500,7 +3577,7 @@ func (s *Service) reconcileActiveSessionOnStartup( // cleared) so it never keeps claiming a live process (#1597 expected behavior). // resume_token/worktree are preserved by the repair. Remote rows report Unknown // and are left to their own runtime's status poll. - if s.rowLiveness(running) == models.ProcessLivenessDead { + if s.rowLivenessScoped(running, scope) == models.ProcessLivenessDead { s.repairDeadRowLiveness(ctx, running) } @@ -3508,17 +3585,18 @@ func (s *Service) reconcileActiveSessionOnStartup( zap.String("session_id", sessionID), zap.String("task_id", running.TaskID), zap.String("previous_state", string(previousState)), + zap.Bool("retracked", retracked), zap.Bool("has_resume_token", running.ResumeToken != ""), zap.Bool("has_worktree", running.WorktreePath != "")) } -func (s *Service) handleMissingSessionOnStartup(ctx context.Context, running *models.ExecutorRunning, report *startupCleanupReport) { +func (s *Service) handleMissingSessionOnStartup(ctx context.Context, running *models.ExecutorRunning, report *startupCleanupReport, scope interface{}) { sessionID := running.SessionID executionID := strings.TrimSpace(running.AgentExecutionID) if executionID == "" || s.agentManager == nil { decision := startupCleanupDecision{ disposition: startupCleanupDispositionPreserved, - liveness: processLivenessClass(s.rowLiveness(running)), + liveness: processLivenessClass(s.rowLivenessScoped(running, scope)), stopErrorClass: "missing_stop_handle", expected: true, } @@ -3534,7 +3612,7 @@ func (s *Service) handleMissingSessionOnStartup(ctx context.Context, running *mo // invariant, so the orphan row does not survive restarts forever. Any // other error (alive/unknown/remote row, or a non-not-found failure) is // preserved and left for a later attempt. - decision := classifyStartupCleanup(err, s.rowLiveness(running)) + decision := classifyStartupCleanup(err, s.rowLivenessScoped(running, scope)) if !decision.proceed { if decision.expected { report.recordExpected(running, decision) @@ -3578,7 +3656,7 @@ func isTaskSessionNotFound(err error) bool { // handleTerminalSessionOnStartup processes sessions in terminal states during startup. // Returns true if the session should be skipped (no further processing needed). -func (s *Service) handleTerminalSessionOnStartup(ctx context.Context, session *models.TaskSession, running *models.ExecutorRunning, previousState models.TaskSessionState, report *startupCleanupReport) bool { +func (s *Service) handleTerminalSessionOnStartup(ctx context.Context, session *models.TaskSession, running *models.ExecutorRunning, previousState models.TaskSessionState, report *startupCleanupReport, scope interface{}) bool { sessionID := session.ID switch previousState { case models.TaskSessionStateCompleted, models.TaskSessionStateCancelled: @@ -3587,7 +3665,7 @@ func (s *Service) handleTerminalSessionOnStartup(ctx context.Context, session *m zap.String("task_id", session.TaskID), zap.String("state", string(previousState))) s.abandonOpenTurnsOnStartup(ctx, sessionID, "terminal session cleanup") - if !s.stopRuntimeForStartupCleanup(ctx, running, "startup terminal session cleanup", report) { + if !s.stopRuntimeForStartupCleanup(ctx, running, "startup terminal session cleanup", report, scope) { return true } // Resume-safety invariant: prune the row only if it is not still resumable @@ -3597,14 +3675,14 @@ func (s *Service) handleTerminalSessionOnStartup(ctx context.Context, session *m s.pruneOrRepairExecutorRow(ctx, running, previousState) return true case models.TaskSessionStateFailed: - s.handleFailedSessionOnStartup(ctx, session, running, report) + s.handleFailedSessionOnStartup(ctx, session, running, report, scope) return true } return false } // handleFailedSessionOnStartup handles a failed session during startup recovery. -func (s *Service) handleFailedSessionOnStartup(ctx context.Context, session *models.TaskSession, running *models.ExecutorRunning, report *startupCleanupReport) { +func (s *Service) handleFailedSessionOnStartup(ctx context.Context, session *models.TaskSession, running *models.ExecutorRunning, report *startupCleanupReport, scope interface{}) { sessionID := session.ID s.abandonOpenTurnsOnStartup(ctx, sessionID, "failed session cleanup") // If session failed, ensure task is in REVIEW state (not stuck IN_PROGRESS) @@ -3632,7 +3710,7 @@ func (s *Service) handleFailedSessionOnStartup(ctx context.Context, session *mod s.logger.Info("stopping failed session runtime before cleaning up executor record", zap.String("session_id", sessionID), zap.String("task_id", session.TaskID)) - if !s.stopRuntimeForStartupCleanup(ctx, running, "startup failed session cleanup", report) { + if !s.stopRuntimeForStartupCleanup(ctx, running, "startup failed session cleanup", report, scope) { return } // Prune only subject to the resume-safety invariant (a lingering @@ -3650,10 +3728,10 @@ func (s *Service) handleFailedSessionOnStartup(ctx context.Context, session *mod // (alive/unknown/remote row, or a non-not-found failure) preserves the row for a // later attempt. A row with no stoppable handle proceeds only when its local // process liveness is confirmed dead; alive and Unknown rows remain durable. -func (s *Service) stopRuntimeForStartupCleanup(ctx context.Context, running *models.ExecutorRunning, reason string, report *startupCleanupReport) bool { +func (s *Service) stopRuntimeForStartupCleanup(ctx context.Context, running *models.ExecutorRunning, reason string, report *startupCleanupReport, scope interface{}) bool { executionID := strings.TrimSpace(running.AgentExecutionID) if executionID == "" || s.agentManager == nil { - liveness := s.rowLiveness(running) + liveness := s.rowLivenessScoped(running, scope) decision := startupCleanupDecision{ disposition: startupCleanupDispositionPreserved, liveness: processLivenessClass(liveness), @@ -3679,7 +3757,7 @@ func (s *Service) stopRuntimeForStartupCleanup(ctx context.Context, running *mod if err == nil { return true } - decision := classifyStartupCleanup(err, s.rowLiveness(running)) + decision := classifyStartupCleanup(err, s.rowLivenessScoped(running, scope)) if !decision.proceed { fields := append(startupCleanupFields(running, decision), zap.String("reason", reason)) if decision.expected { diff --git a/apps/backend/internal/orchestrator/session_recovery_guard_error.go b/apps/backend/internal/orchestrator/session_recovery_guard_error.go new file mode 100644 index 0000000000..60e1b48d17 --- /dev/null +++ b/apps/backend/internal/orchestrator/session_recovery_guard_error.go @@ -0,0 +1,45 @@ +package orchestrator + +const detailsKeyKind = "kind" + +// SessionRecoveryGuardError identifies a session launch refused by the +// startup recovery guard. It wraps the original sentinel error so callers +// can still use errors.Is/errors.As. +type SessionRecoveryGuardError struct { + Cause error + SessionID string + Retryable bool +} + +func (e *SessionRecoveryGuardError) Error() string { + if e == nil { + return "session launch is blocked by the recovery guard" + } + if e.Retryable { + return "the session is still being reconciled after a backend restart; try again shortly" + } + return "the session's agent could not be stopped after a backend restart and requires another restart before it can be launched" +} + +func (e *SessionRecoveryGuardError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// Details returns the stable WebSocket recovery contract for this error. +func (e *SessionRecoveryGuardError) Details() map[string]interface{} { + if e == nil { + return nil + } + kind := "session_recovery_unstoppable" + if e.Retryable { + kind = "session_recovery_in_progress" + } + return map[string]interface{}{ + detailsKeyKind: kind, + "retryable": e.Retryable, + metaKeySessionID: e.SessionID, + } +} diff --git a/apps/backend/internal/orchestrator/session_recovery_guard_error_test.go b/apps/backend/internal/orchestrator/session_recovery_guard_error_test.go new file mode 100644 index 0000000000..c02fec3511 --- /dev/null +++ b/apps/backend/internal/orchestrator/session_recovery_guard_error_test.go @@ -0,0 +1,42 @@ +package orchestrator + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSessionRecoveryGuardErrorRetryablePreservesTypedCauseAndDetails(t *testing.T) { + cause := errors.New("session is guarded pending recovery") + err := &SessionRecoveryGuardError{Cause: cause, SessionID: "session-1", Retryable: true} + + require.ErrorIs(t, error(err), cause) + require.Equal(t, map[string]interface{}{ + "kind": "session_recovery_in_progress", + "retryable": true, + "session_id": "session-1", + }, err.Details()) + require.Contains(t, err.Error(), "try again shortly") +} + +func TestSessionRecoveryGuardErrorNonRetryablePreservesTypedCauseAndDetails(t *testing.T) { + cause := errors.New("session has an unstoppable agent from a prior launch") + err := &SessionRecoveryGuardError{Cause: cause, SessionID: "session-2", Retryable: false} + + require.ErrorIs(t, error(err), cause) + require.Equal(t, map[string]interface{}{ + "kind": "session_recovery_unstoppable", + "retryable": false, + "session_id": "session-2", + }, err.Details()) + require.Contains(t, err.Error(), "requires another restart") +} + +func TestSessionRecoveryGuardErrorNilReceiverIsSafe(t *testing.T) { + var err *SessionRecoveryGuardError + + require.Nil(t, err.Unwrap()) + require.Nil(t, err.Details()) + require.Contains(t, err.Error(), "blocked by the recovery guard") +} diff --git a/apps/backend/internal/profiles/profiles.yaml b/apps/backend/internal/profiles/profiles.yaml index f3873f3e28..4ad4c382be 100644 --- a/apps/backend/internal/profiles/profiles.yaml +++ b/apps/backend/internal/profiles/profiles.yaml @@ -106,6 +106,15 @@ features: dev: "true" e2e: "true" + # Agent survival across a backend restart (worktree/local executors only). + # Off in every profile: it replaces the standalone control server's + # kill-on-restart safeguard with an adoption handshake, and is unavailable + # on Windows. E2E specs that need it set the env var per-spec. + KANDEV_FEATURES_AGENT_SURVIVAL: + prod: "false" + dev: "false" + e2e: "false" + # ── Mock providers ──────────────────────────────────────────────── # Swap real third-party services for in-memory fakes. Production # leaves all of these unset (real APIs). Dev opts into the mock agent diff --git a/apps/backend/internal/runtimeflags/availability_test.go b/apps/backend/internal/runtimeflags/availability_test.go new file mode 100644 index 0000000000..fef23e1abc --- /dev/null +++ b/apps/backend/internal/runtimeflags/availability_test.go @@ -0,0 +1,112 @@ +package runtimeflags + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/common/config" +) + +// TestStateForReportsAvailabilityFromProbe pins the additive availability +// mechanism from system-design/agent-survival-across-restart-02.md: a nil-able +// probe on the flag definition, present in the serialized state only when the +// flag is unavailable, and an unavailable flag behaves as disabled regardless +// of its stored value. +func TestStateForReportsAvailabilityFromProbe(t *testing.T) { + svc := NewService(&memoryStore{}, Options{ + DefaultValues: map[string]bool{"features.testProbe": true}, + RuntimeValues: map[string]bool{"features.testProbe": true}, + IsExplicitEnv: func(string) bool { return false }, + }) + def := RuntimeFlagDefinition{ + Key: "features.testProbe", + EnvVar: "KANDEV_FEATURES_TEST_PROBE", + Kind: KindFeature, + Label: "Test probe flag", + Description: "test", + Stability: StabilityExperimental, + RiskLevel: RiskHigh, + RiskDescription: "test", + RestartRequired: true, + Mutable: true, + Available: func() (bool, string) { return false, "platform_unsupported" }, + } + + state := svc.stateFor(def, map[string]bool{"features.testProbe": true}) + + if state.Availability == nil { + t.Fatal("Availability = nil, want a populated availability object for an unavailable flag") + } + if state.Availability.Available { + t.Fatal("Availability.Available = true, want false") + } + if state.Availability.ReasonCode != "platform_unsupported" { + t.Fatalf("Availability.ReasonCode = %q, want %q", state.Availability.ReasonCode, "platform_unsupported") + } + if state.EffectiveValue { + t.Fatal("EffectiveValue = true, want false: an unavailable flag must behave as disabled regardless of its stored override") + } +} + +func TestStateForNilProbeReportsNoAvailabilityObject(t *testing.T) { + svc := NewService(&memoryStore{}, Options{ + DefaultValues: map[string]bool{"features.office": false}, + RuntimeValues: map[string]bool{"features.office": false}, + IsExplicitEnv: func(string) bool { return false }, + }) + def, ok := DefinitionByKey("features.office") + if !ok { + t.Fatal("features.office definition missing") + } + state := svc.stateFor(def, nil) + if state.Availability != nil { + t.Fatalf("Availability = %+v, want nil when the definition has no probe", state.Availability) + } +} + +// TestSetOverrideRefusesEnablingAnUnavailableFlag pins that turning an +// unavailable flag on is refused rather than silently stored, while clearing +// or disabling it is still allowed. The probe is injected on a throwaway +// registration since the real agent-survival probe is host-dependent. +func TestSetOverrideRefusesEnablingAnUnavailableFlag(t *testing.T) { + const key = "features.testProbeRefuse" + registrations = append(registrations, runtimeFlagRegistration{ + definition: RuntimeFlagDefinition{ + Key: key, + EnvVar: "KANDEV_FEATURES_TEST_PROBE_REFUSE", + Kind: KindFeature, + Label: "Test probe refuse flag", + Description: "test", + Stability: StabilityExperimental, + RiskLevel: RiskHigh, + RiskDescription: "test", + RestartRequired: true, + Mutable: true, + Available: func() (bool, string) { return false, "platform_unsupported" }, + }, + read: func(*config.Config) bool { return false }, + apply: func(*config.Config, bool) {}, + }) + t.Cleanup(func() { registrations = registrations[:len(registrations)-1] }) + + svc := NewService(&memoryStore{}, Options{ + DefaultValues: map[string]bool{key: false}, + RuntimeValues: map[string]bool{key: false}, + IsExplicitEnv: func(string) bool { return false }, + }) + + enable := true + if _, err := svc.SetOverride(context.Background(), key, &enable); !errors.Is(err, ErrFlagUnavailable) { + t.Fatalf("SetOverride(true) error = %v, want %v", err, ErrFlagUnavailable) + } + + disable := false + if _, err := svc.SetOverride(context.Background(), key, &disable); err != nil { + t.Fatalf("SetOverride(false) on an unavailable flag: %v", err) + } + + if _, err := svc.SetOverride(context.Background(), key, nil); err != nil { + t.Fatalf("SetOverride(nil) on an unavailable flag: %v", err) + } +} diff --git a/apps/backend/internal/runtimeflags/handlers.go b/apps/backend/internal/runtimeflags/handlers.go index 0cfef37676..bbc263c6a1 100644 --- a/apps/backend/internal/runtimeflags/handlers.go +++ b/apps/backend/internal/runtimeflags/handlers.go @@ -54,6 +54,9 @@ func (h *Handler) patch(c *gin.Context) { } else if errors.Is(err, ErrUnknownFlag) { status = http.StatusNotFound msg = "runtime flag not found" + } else if errors.Is(err, ErrFlagUnavailable) { + status = http.StatusConflict + msg = "runtime flag is unavailable on this host" } c.JSON(status, gin.H{"error": msg}) return diff --git a/apps/backend/internal/runtimeflags/registry.go b/apps/backend/internal/runtimeflags/registry.go index 987ed24b78..6ef786e90b 100644 --- a/apps/backend/internal/runtimeflags/registry.go +++ b/apps/backend/internal/runtimeflags/registry.go @@ -1,6 +1,26 @@ package runtimeflags -import "github.com/kandev/kandev/internal/common/config" +import ( + "runtime" + + "github.com/kandev/kandev/internal/common/config" +) + +// ReasonPlatformUnsupported is the stable, machine-readable reason code for a +// flag that requires a host platform this install does not run on. The +// frontend translates it; it is never shown to the operator as raw text. +const ReasonPlatformUnsupported = "platform_unsupported" + +// agentSurvivalAvailability implements the platform scope decision in +// system-design/agent-survival-across-restart-02.md: survival is supported on +// macOS and Linux, and unavailable on Windows, where it would trade the +// platform's kill-on-job-close safeguard for an untested adoption handshake. +func agentSurvivalAvailability() (bool, string) { + if runtime.GOOS == "windows" { + return false, ReasonPlatformUnsupported + } + return true, "" +} // runtimeFlagRegistration keeps the public metadata and the typed config // binding for a flag together. The function fields stay internal so the HTTP @@ -177,6 +197,25 @@ var registrations = []runtimeFlagRegistration{ read: func(cfg *config.Config) bool { return cfg.Features.OfficeSessionIdentity }, apply: func(cfg *config.Config, value bool) { cfg.Features.OfficeSessionIdentity = value }, }, + { + definition: RuntimeFlagDefinition{ + Key: "features.agentSurvival", + EnvVar: "KANDEV_FEATURES_AGENT_SURVIVAL", + Kind: KindFeature, + Label: "Agent survival across backend restart", + Description: "Lets a worktree or local-executor agent session survive a backend restart by adopting its still-running standalone control server instead of killing it.", + Stability: StabilityExperimental, + RiskLevel: RiskHigh, + RiskDescription: "Replaces the standalone control server's kill-on-restart safeguard with an adoption handshake. " + + "Enable it only after reviewing the recovery and ownership guarantees, since it changes what happens to an agent " + + "process when the backend restarts unexpectedly. Unavailable on Windows, where the removed safeguard is depended on.", + RestartRequired: true, + Mutable: true, + Available: agentSurvivalAvailability, + }, + read: func(cfg *config.Config) bool { return cfg.Features.AgentSurvival }, + apply: func(cfg *config.Config, value bool) { cfg.Features.AgentSurvival = value }, + }, { definition: RuntimeFlagDefinition{ Key: "debug.devMode", diff --git a/apps/backend/internal/runtimeflags/registry_test.go b/apps/backend/internal/runtimeflags/registry_test.go index 66a2824bbe..6f8307784a 100644 --- a/apps/backend/internal/runtimeflags/registry_test.go +++ b/apps/backend/internal/runtimeflags/registry_test.go @@ -2,6 +2,7 @@ package runtimeflags import ( "reflect" + "runtime" "strings" "testing" @@ -476,3 +477,64 @@ func TestDefinitionsIncludeClaudeMidTurnSteeringMetadata(t *testing.T) { t.Fatal("RestartRequired = false, want true") } } + +// TestDefinitionsIncludeAgentSurvivalMetadata pins the registration for +// AC-EXECUTORS-SURVIVAL-005.2/5.4: off by default, restart-required, and +// carrying a host-availability probe (checked separately below). +func TestDefinitionsIncludeAgentSurvivalMetadata(t *testing.T) { + def, ok := DefinitionByKey("features.agentSurvival") + if !ok { + t.Fatal("features.agentSurvival definition missing") + } + if def.EnvVar != "KANDEV_FEATURES_AGENT_SURVIVAL" { + t.Fatalf("EnvVar = %q, want KANDEV_FEATURES_AGENT_SURVIVAL", def.EnvVar) + } + if def.Stability != StabilityExperimental { + t.Fatalf("Stability = %q, want experimental", def.Stability) + } + if def.RiskLevel != RiskHigh { + t.Fatalf("RiskLevel = %q, want high", def.RiskLevel) + } + if def.RiskDescription == "" { + t.Fatal("RiskDescription empty") + } + if !def.RestartRequired { + t.Fatal("RestartRequired = false, want true") + } + if !def.Mutable { + t.Fatal("Mutable = false, want true") + } + if def.Available == nil { + t.Fatal("Available probe missing; AC-EXECUTORS-SURVIVAL-005.4 requires the platform-unsupported state to be reportable") + } + defaults, err := profiles.FeatureFlagDefaults() + if err != nil { + t.Fatalf("profiles.FeatureFlagDefaults: %v", err) + } + if got := defaults["agent_survival"]; got != "false" { + t.Fatalf("profiles.yaml agent_survival default = %q, want %q (AC-EXECUTORS-SURVIVAL-005.2: off by default)", got, "false") + } +} + +// TestAgentSurvivalAvailabilityMatchesPlatformScope pins the platform-scope +// decision (macOS + Linux only) against whatever GOOS this test actually runs +// on, so the Windows CI job exercises the unavailable branch for real instead +// of every job only ever seeing "available". +func TestAgentSurvivalAvailabilityMatchesPlatformScope(t *testing.T) { + available, reasonCode := agentSurvivalAvailability() + if runtime.GOOS == "windows" { + if available { + t.Fatal("agentSurvivalAvailability() = available, want unavailable on Windows") + } + if reasonCode != ReasonPlatformUnsupported { + t.Fatalf("reasonCode = %q, want %q", reasonCode, ReasonPlatformUnsupported) + } + return + } + if !available { + t.Fatalf("agentSurvivalAvailability() = unavailable (%q), want available on %s", reasonCode, runtime.GOOS) + } + if reasonCode != "" { + t.Fatalf("reasonCode = %q, want empty when available", reasonCode) + } +} diff --git a/apps/backend/internal/runtimeflags/service.go b/apps/backend/internal/runtimeflags/service.go index f39fb8d004..74461afb6c 100644 --- a/apps/backend/internal/runtimeflags/service.go +++ b/apps/backend/internal/runtimeflags/service.go @@ -8,9 +8,10 @@ import ( ) var ( - ErrEnvLocked = errors.New("runtime flag is controlled by environment") - ErrStoreUnset = errors.New("runtime flag store is not configured") - ErrUnknownFlag = errors.New("unknown runtime flag") + ErrEnvLocked = errors.New("runtime flag is controlled by environment") + ErrStoreUnset = errors.New("runtime flag store is not configured") + ErrUnknownFlag = errors.New("unknown runtime flag") + ErrFlagUnavailable = errors.New("runtime flag is unavailable on this host") ) type Options struct { @@ -60,6 +61,9 @@ func (s *Service) SetOverride(ctx context.Context, key string, value *bool) ([]R if s.isEnvLocked(def) { return nil, ErrEnvLocked } + if value != nil && *value && !s.isAvailable(def) { + return nil, ErrFlagUnavailable + } if value == nil { if err := s.store.DeleteOverride(ctx, key); err != nil { return nil, err @@ -91,6 +95,17 @@ func (s *Service) stateFor(def RuntimeFlagDefinition, overrides map[string]bool) source = SourceDefault } + var availability *RuntimeFlagAvailability + if def.Available != nil { + if available, reasonCode := def.Available(); !available { + // An unavailable flag behaves as disabled whatever its stored + // value, so an admin override cannot leave a host claiming a + // capability it cannot honour. + effectiveValue = false + availability = &RuntimeFlagAvailability{Available: false, ReasonCode: reasonCode} + } + } + return RuntimeFlagState{ Key: def.Key, Kind: def.Kind, @@ -108,6 +123,7 @@ func (s *Service) stateFor(def RuntimeFlagDefinition, overrides map[string]bool) RestartRequired: def.RestartRequired, RequiresRestartToApply: def.RestartRequired && effectiveValue != runtimeValue, Mutable: def.Mutable, + Availability: availability, } } @@ -116,6 +132,14 @@ func (s *Service) isEnvLocked(def RuntimeFlagDefinition) bool { return locked } +func (s *Service) isAvailable(def RuntimeFlagDefinition) bool { + if def.Available == nil { + return true + } + available, _ := def.Available() + return available +} + func (s *Service) envLockedValue(def RuntimeFlagDefinition, fallback bool) (bool, bool) { if s.opts.IsExplicitEnv(def.EnvVar) { return s.envValue(def.EnvVar, fallback), true diff --git a/apps/backend/internal/runtimeflags/types.go b/apps/backend/internal/runtimeflags/types.go index e53570c2a9..aa912d8465 100644 --- a/apps/backend/internal/runtimeflags/types.go +++ b/apps/backend/internal/runtimeflags/types.go @@ -34,6 +34,11 @@ const ( RiskHigh RuntimeFlagRiskLevel = "high" ) +// RuntimeFlagAvailabilityProbe reports whether a flag can be enabled on the +// current host and, when it cannot, a stable machine-readable reason code the +// frontend translates. Nil means always available. +type RuntimeFlagAvailabilityProbe func() (available bool, reasonCode string) + type RuntimeFlagDefinition struct { Key string `json:"key"` EnvVar string `json:"env_var"` @@ -46,25 +51,37 @@ type RuntimeFlagDefinition struct { RestartRequired bool `json:"restart_required"` Mutable bool `json:"mutable"` ImpliedEnvVars []string `json:"-"` + // Available is an optional host-availability probe. Nil (every existing + // registration) means always available, which keeps this additive: no + // other flag's behavior, serialization, or admin-override path changes. + Available RuntimeFlagAvailabilityProbe `json:"-"` +} + +// RuntimeFlagAvailability reports why a flag cannot be enabled on this host. +// Present in RuntimeFlagState only when the flag is unavailable. +type RuntimeFlagAvailability struct { + Available bool `json:"available"` + ReasonCode string `json:"reason_code,omitempty"` } type RuntimeFlagState struct { - Key string `json:"key"` - Kind RuntimeFlagKind `json:"kind"` - Label string `json:"label"` - Description string `json:"description"` - Stability RuntimeFlagStability `json:"stability"` - RiskLevel RuntimeFlagRiskLevel `json:"risk_level"` - RiskDescription string `json:"risk_description"` - EffectiveValue bool `json:"effective_value"` - DefaultValue bool `json:"default_value"` - OverrideValue *bool `json:"override_value"` - Source RuntimeFlagSource `json:"source"` - EnvVar string `json:"env_var"` - EnvLocked bool `json:"env_locked"` - RestartRequired bool `json:"restart_required"` - RequiresRestartToApply bool `json:"requires_restart_to_apply"` - Mutable bool `json:"mutable"` + Key string `json:"key"` + Kind RuntimeFlagKind `json:"kind"` + Label string `json:"label"` + Description string `json:"description"` + Stability RuntimeFlagStability `json:"stability"` + RiskLevel RuntimeFlagRiskLevel `json:"risk_level"` + RiskDescription string `json:"risk_description"` + EffectiveValue bool `json:"effective_value"` + DefaultValue bool `json:"default_value"` + OverrideValue *bool `json:"override_value"` + Source RuntimeFlagSource `json:"source"` + EnvVar string `json:"env_var"` + EnvLocked bool `json:"env_locked"` + RestartRequired bool `json:"restart_required"` + RequiresRestartToApply bool `json:"requires_restart_to_apply"` + Mutable bool `json:"mutable"` + Availability *RuntimeFlagAvailability `json:"availability,omitempty"` } type Store interface { diff --git a/apps/backend/internal/task/models/control_server_record.go b/apps/backend/internal/task/models/control_server_record.go new file mode 100644 index 0000000000..b64426d403 --- /dev/null +++ b/apps/backend/internal/task/models/control_server_record.go @@ -0,0 +1,46 @@ +package models + +import ( + "errors" + "time" +) + +// ErrControlServerRecordNotFound is returned when no control-server record +// has been written yet, e.g. before the standalone control server is ever +// started or adopted on this installation. +var ErrControlServerRecordNotFound = errors.New("control server record not found") + +// ControlServerRecord is the single installation-scoped durable record of the +// standalone agentctl control server that supervises every worktree and local +// agent instance on this host: where it is, how to prove ownership of it, and +// what it can do. Exactly one row exists per installation. +// +// It is deliberately separate from ExecutorRunning: that table is per-session +// and has no row when no session is live, which would leave a detached, +// instance-less server unlocatable. Written when a server is started or +// adopted, read before any control-server contact at startup. +type ControlServerRecord struct { + // Endpoint is the control server's host:port. The launcher may relocate to + // a free port when the configured one is busy, so this is the only durable + // way a restarted backend can find a relocated survivor. + Endpoint string `json:"endpoint"` + // ServerIdentity is the opaque per-launch value the control server + // echoes on its identity endpoint, compared by the adopting backend to + // confirm it is talking to the server this record describes. + ServerIdentity string `json:"server_identity"` + // CredentialSecretID is a reference into the secret store for the single + // rotating credential that authenticates both control-plane and + // per-instance operations and streams (design 01 "Single driver", + // AC-EXECUTORS-CONTROL-OWNERSHIP-002.6). The credential itself is never + // stored here: this is a reference, not the bearer token. + CredentialSecretID string `json:"credential_secret_id"` + // Capabilities is the named capability set last observed from the + // control server's identity endpoint. + Capabilities []string `json:"capabilities"` + // DiagnosticLogPath is the location of the control server's detached + // diagnostic-output log sink, so both the next backend and an operator + // can find it. + DiagnosticLogPath string `json:"diagnostic_log_path"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index 3e71285f54..7d2f0255a8 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -37,6 +37,7 @@ func (r *Repository) initSchemaContext(ctx context.Context) error { r.initTaskUsageEventsSchema, r.initAttachmentsSchema, r.initTaskResourceCleanupSchema, + r.initControlServerRecordSchema, r.initGitSchema, r.initReviewSchema, r.initTaskReviewSchema, @@ -185,6 +186,28 @@ func (r *Repository) initTaskResourceCleanupSchema() error { return err } +// control_server_records is the single installation-scoped durable record of +// the standalone agentctl control server (see models.ControlServerRecord). +// The id CHECK enforces exactly one row, the same singleton pattern used by +// dynamic_installation_keys above. +const controlServerRecordSchemaDDL = ` + CREATE TABLE IF NOT EXISTS control_server_records ( + id INTEGER PRIMARY KEY CHECK (id = 1), + endpoint TEXT NOT NULL, + server_identity TEXT NOT NULL, + credential_secret_id TEXT NOT NULL, + capabilities TEXT NOT NULL DEFAULT '[]', + diagnostic_log_path TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); +` + +func (r *Repository) initControlServerRecordSchema() error { + _, err := r.db.Exec(controlServerRecordSchemaDDL) + return err +} + // ensureWorkspaceIndexes creates workspace-related indexes func (r *Repository) ensureWorkspaceIndexes() error { if _, err := r.db.ExecContext(r.migrationContext(), `CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id)`); err != nil { diff --git a/apps/backend/internal/task/repository/sqlite/control_server_record.go b/apps/backend/internal/task/repository/sqlite/control_server_record.go new file mode 100644 index 0000000000..0c4b95b199 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/control_server_record.go @@ -0,0 +1,94 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +// GetControlServerRecord reads the single installation-scoped control-server +// record. Startup step 4 reads this before any control-server contact: with +// no record (models.ErrControlServerRecordNotFound), the caller spawns a +// fresh server and reports no recovered instances. +func (r *Repository) GetControlServerRecord(ctx context.Context) (*models.ControlServerRecord, error) { + record := &models.ControlServerRecord{} + var capabilitiesJSON string + + err := r.ro.QueryRowContext(ctx, ` + SELECT endpoint, server_identity, credential_secret_id, capabilities, diagnostic_log_path, created_at, updated_at + FROM control_server_records WHERE id = 1 + `).Scan( + &record.Endpoint, &record.ServerIdentity, &record.CredentialSecretID, &capabilitiesJSON, + &record.DiagnosticLogPath, &record.CreatedAt, &record.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, models.ErrControlServerRecordNotFound + } + if err != nil { + return nil, err + } + + if capabilitiesJSON != "" { + if err := json.Unmarshal([]byte(capabilitiesJSON), &record.Capabilities); err != nil { + return nil, fmt.Errorf("failed to deserialize control server record capabilities: %w", err) + } + } + return record, nil +} + +// UpsertControlServerRecord writes the single installation-scoped +// control-server record, replacing whatever record was there before. A +// server started after a refused or failed adoption rewrites the record to +// name the new server: the record cannot keep pointing at a server that is +// about to reap itself. CreatedAt is preserved across a rewrite. +func (r *Repository) UpsertControlServerRecord(ctx context.Context, record *models.ControlServerRecord) error { + if record == nil { + return fmt.Errorf("control server record is nil") + } + + now := time.Now().UTC() + if record.CreatedAt.IsZero() { + record.CreatedAt = now + } + record.UpdatedAt = now + + capabilities := record.Capabilities + if capabilities == nil { + capabilities = []string{} + } + capabilitiesJSON, err := json.Marshal(capabilities) + if err != nil { + return fmt.Errorf("failed to serialize control server record capabilities: %w", err) + } + + _, err = r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO control_server_records ( + id, endpoint, server_identity, credential_secret_id, capabilities, diagnostic_log_path, created_at, updated_at + ) VALUES (1, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + endpoint = excluded.endpoint, + server_identity = excluded.server_identity, + credential_secret_id = excluded.credential_secret_id, + capabilities = excluded.capabilities, + diagnostic_log_path = excluded.diagnostic_log_path, + updated_at = excluded.updated_at + `), + record.Endpoint, + record.ServerIdentity, + record.CredentialSecretID, + string(capabilitiesJSON), + record.DiagnosticLogPath, + record.CreatedAt, + record.UpdatedAt, + ) + // created_at is deliberately absent from the UPDATE SET list above, so a + // rewrite after a prior record's row keeps the original creation time in + // the database even though the in-memory record above was stamped with + // this call's now. + return err +} diff --git a/apps/backend/internal/task/repository/sqlite/control_server_record_postgres_test.go b/apps/backend/internal/task/repository/sqlite/control_server_record_postgres_test.go new file mode 100644 index 0000000000..a49c1dd004 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/control_server_record_postgres_test.go @@ -0,0 +1,95 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +// TestPostgresControlServerRecordRoundTrips is the PostgreSQL counterpart to +// TestUpsertControlServerRecordThenGetRoundTrips/TestUpsertControlServerRecordIsSingleton +// (SQLite): control_server_records is a new table reached through raw SQL +// (an ON CONFLICT(id) DO UPDATE upsert plus a JSON-encoded capabilities +// column), so schema replay alone does not prove the upsert, singleton, and +// CreatedAt-preservation behavior actually holds on this dialect too. +// Skips unless KANDEV_TEST_POSTGRES_DSN is set. +func TestPostgresControlServerRecordRoundTrips(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + + if _, err := repo.GetControlServerRecord(ctx); err == nil { + t.Fatalf("expected ErrControlServerRecordNotFound before any write") + } + + first := &models.ControlServerRecord{ + Endpoint: "127.0.0.1:41123", + ServerIdentity: "server-identity-1", + CredentialSecretID: "secret-ref-1", + Capabilities: []string{"resume", "diagnostics"}, + DiagnosticLogPath: "/home/kandev/logs/agentctl-diagnostic.log", + } + if err := repo.UpsertControlServerRecord(ctx, first); err != nil { + t.Fatalf("UpsertControlServerRecord(first): %v", err) + } + firstCreatedAt := first.CreatedAt + + got, err := repo.GetControlServerRecord(ctx) + if err != nil { + t.Fatalf("GetControlServerRecord: %v", err) + } + if got.Endpoint != first.Endpoint || got.ServerIdentity != first.ServerIdentity || + got.CredentialSecretID != first.CredentialSecretID || got.DiagnosticLogPath != first.DiagnosticLogPath { + t.Fatalf("got = %#v, want match of %#v", got, first) + } + if len(got.Capabilities) != 2 || got.Capabilities[0] != "resume" || got.Capabilities[1] != "diagnostics" { + t.Fatalf("Capabilities = %#v, want [resume diagnostics]", got.Capabilities) + } + + time.Sleep(2 * time.Millisecond) + + second := &models.ControlServerRecord{ + Endpoint: "127.0.0.1:52222", + ServerIdentity: "server-identity-2", + CredentialSecretID: "secret-ref-2", + DiagnosticLogPath: "/home/kandev/logs/agentctl-diagnostic.log", + } + if err := repo.UpsertControlServerRecord(ctx, second); err != nil { + t.Fatalf("UpsertControlServerRecord(second): %v", err) + } + + got2, err := repo.GetControlServerRecord(ctx) + if err != nil { + t.Fatalf("GetControlServerRecord(after rewrite): %v", err) + } + if got2.Endpoint != second.Endpoint || got2.ServerIdentity != second.ServerIdentity { + t.Fatalf("got2 = %#v, want the rewritten (second) record", got2) + } + // Compared at microsecond granularity because PostgreSQL TIMESTAMP stores + // microseconds while Go's time.Time carries nanoseconds, so the first + // write's in-memory stamp reads back truncated (.330254029 -> .330254). + // That truncation is a storage property, not the contract under test: the + // regression this guards is an upsert that RESETS created_at to now, which + // the 2ms sleep above puts milliseconds away and which a microsecond + // comparison still catches with room to spare. + if !got2.CreatedAt.Truncate(time.Microsecond).Equal(firstCreatedAt.Truncate(time.Microsecond)) { + t.Fatalf("CreatedAt = %v, want preserved original %v", got2.CreatedAt, firstCreatedAt) + } + if !got2.UpdatedAt.After(got2.CreatedAt) { + t.Fatalf("UpdatedAt = %v, want after CreatedAt %v", got2.UpdatedAt, got2.CreatedAt) + } + + var count int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM control_server_records`).Scan(&count); err != nil { + t.Fatalf("count rows: %v", err) + } + if count != 1 { + t.Fatalf("row count = %d, want 1", count) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/control_server_record_test.go b/apps/backend/internal/task/repository/sqlite/control_server_record_test.go new file mode 100644 index 0000000000..e3f11910c2 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/control_server_record_test.go @@ -0,0 +1,122 @@ +package sqlite + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +// TestGetControlServerRecordNotFound pins startup step 4's "no record" +// branch: before any server has ever been started or adopted on this +// installation, the read returns the distinguishable not-found sentinel +// rather than an empty record, so the caller can tell "spawn fresh" apart +// from "record read failed". +func TestGetControlServerRecordNotFound(t *testing.T) { + repo := newRepoForSessionTests(t) + + _, err := repo.GetControlServerRecord(context.Background()) + if !errors.Is(err, models.ErrControlServerRecordNotFound) { + t.Fatalf("err = %v, want %v", err, models.ErrControlServerRecordNotFound) + } +} + +// TestUpsertControlServerRecordThenGetRoundTrips pins that every field +// written by UpsertControlServerRecord (endpoint, identity, credential +// reference, capability set, diagnostic log location) survives a round trip +// unchanged. +func TestUpsertControlServerRecordThenGetRoundTrips(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + + record := &models.ControlServerRecord{ + Endpoint: "127.0.0.1:41123", + ServerIdentity: "server-identity-1", + CredentialSecretID: "secret-ref-1", + Capabilities: []string{"resume", "diagnostics"}, + DiagnosticLogPath: "/home/kandev/logs/agentctl-diagnostic.log", + } + if err := repo.UpsertControlServerRecord(ctx, record); err != nil { + t.Fatalf("UpsertControlServerRecord: %v", err) + } + + got, err := repo.GetControlServerRecord(ctx) + if err != nil { + t.Fatalf("GetControlServerRecord: %v", err) + } + if got.Endpoint != record.Endpoint { + t.Errorf("Endpoint = %q, want %q", got.Endpoint, record.Endpoint) + } + if got.ServerIdentity != record.ServerIdentity { + t.Errorf("ServerIdentity = %q, want %q", got.ServerIdentity, record.ServerIdentity) + } + if got.CredentialSecretID != record.CredentialSecretID { + t.Errorf("CredentialSecretID = %q, want %q", got.CredentialSecretID, record.CredentialSecretID) + } + if len(got.Capabilities) != 2 || got.Capabilities[0] != "resume" || got.Capabilities[1] != "diagnostics" { + t.Errorf("Capabilities = %#v, want [resume diagnostics]", got.Capabilities) + } + if got.DiagnosticLogPath != record.DiagnosticLogPath { + t.Errorf("DiagnosticLogPath = %q, want %q", got.DiagnosticLogPath, record.DiagnosticLogPath) + } + if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() { + t.Errorf("CreatedAt/UpdatedAt not set: %#v", got) + } +} + +// TestUpsertControlServerRecordIsSingleton pins the installation-scoped +// invariant: a second write replaces the one existing row (own server started +// after a refused or failed adoption rewrites the record to name the new +// server) rather than creating a second row, and CreatedAt is preserved +// across the rewrite while UpdatedAt advances. +func TestUpsertControlServerRecordIsSingleton(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + + first := &models.ControlServerRecord{ + Endpoint: "127.0.0.1:41123", + ServerIdentity: "server-identity-1", + CredentialSecretID: "secret-ref-1", + DiagnosticLogPath: "/home/kandev/logs/agentctl-diagnostic.log", + } + if err := repo.UpsertControlServerRecord(ctx, first); err != nil { + t.Fatalf("UpsertControlServerRecord(first): %v", err) + } + firstCreatedAt := first.CreatedAt + + time.Sleep(2 * time.Millisecond) + + second := &models.ControlServerRecord{ + Endpoint: "127.0.0.1:52222", + ServerIdentity: "server-identity-2", + CredentialSecretID: "secret-ref-2", + DiagnosticLogPath: "/home/kandev/logs/agentctl-diagnostic.log", + } + if err := repo.UpsertControlServerRecord(ctx, second); err != nil { + t.Fatalf("UpsertControlServerRecord(second): %v", err) + } + + got, err := repo.GetControlServerRecord(ctx) + if err != nil { + t.Fatalf("GetControlServerRecord: %v", err) + } + if got.Endpoint != second.Endpoint || got.ServerIdentity != second.ServerIdentity { + t.Fatalf("got = %#v, want the rewritten (second) record", got) + } + if !got.CreatedAt.Equal(firstCreatedAt) { + t.Errorf("CreatedAt = %v, want preserved original %v", got.CreatedAt, firstCreatedAt) + } + if !got.UpdatedAt.After(got.CreatedAt) { + t.Errorf("UpdatedAt = %v, want after CreatedAt %v", got.UpdatedAt, got.CreatedAt) + } + + var count int + if err := repo.ro.QueryRowContext(ctx, `SELECT COUNT(*) FROM control_server_records`).Scan(&count); err != nil { + t.Fatalf("count rows: %v", err) + } + if count != 1 { + t.Fatalf("row count = %d, want 1", count) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/executor.go b/apps/backend/internal/task/repository/sqlite/executor.go index 7166b8a8e5..9d37afde1e 100644 --- a/apps/backend/internal/task/repository/sqlite/executor.go +++ b/apps/backend/internal/task/repository/sqlite/executor.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/agentruntime" kandevdb "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/task/models" @@ -298,6 +299,29 @@ func (r *Repository) ListExecutorsRunningIdle(ctx context.Context, cutoff time.T return scanExecutorRunningRows(rows) } +// ListExecutorsRunningLiveStandalone returns every non-terminal executors_running +// row whose runtime is the standalone control server (worktree/local executors). +// Used by startup recovery to build the guard/correlation inventory at step 3, +// before any control-server contact (discovery H). +func (r *Repository) ListExecutorsRunningLiveStandalone(ctx context.Context) ([]*models.ExecutorRunning, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT id, session_id, task_id, execution_profile_id, executor_id, runtime, status, resumable, resume_token, + last_message_uuid, agent_execution_id, container_id, agentctl_url, agentctl_port, pid, local_pid, + worktree_id, worktree_path, worktree_branch, last_seen_at, error_message, metadata, + created_at, updated_at + FROM executors_running + WHERE runtime = ? AND status NOT IN (?, ?, ?) + ORDER BY updated_at DESC + `), agentruntime.RuntimeStandalone, + models.ExecutorRunningStatusStopped, models.ExecutorRunningStatusComplete, models.ExecutorRunningStatusFailed) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + return scanExecutorRunningRows(rows) +} + func (r *Repository) ListExecutorsRunningByTaskID(ctx context.Context, taskID string) ([]*models.ExecutorRunning, error) { if taskID == "" { return nil, fmt.Errorf("task_id is required") diff --git a/apps/backend/internal/task/repository/sqlite/executor_running_live_standalone_test.go b/apps/backend/internal/task/repository/sqlite/executor_running_live_standalone_test.go new file mode 100644 index 0000000000..918c91a9e4 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/executor_running_live_standalone_test.go @@ -0,0 +1,68 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/agentruntime" + "github.com/kandev/kandev/internal/task/models" +) + +// TestListExecutorsRunningLiveStandaloneFiltersRuntimeAndStatus pins the +// startup recovery inventory query (discovery H): only rows on the standalone +// control server (worktree/local) that are not yet terminal are candidates +// for guard-taking and re-tracking correlation at startup step 3, before any +// control-server contact. +func TestListExecutorsRunningLiveStandaloneFiltersRuntimeAndStatus(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + + seedExecutorRunningCleanupTask(t, repo, "task-1") + + seedLiveStandaloneRow(t, repo, "task-1", "session-live-standalone", agentruntime.RuntimeStandalone, models.ExecutorRunningStatusRunning) + seedLiveStandaloneRow(t, repo, "task-1", "session-stopped-standalone", agentruntime.RuntimeStandalone, models.ExecutorRunningStatusStopped) + seedLiveStandaloneRow(t, repo, "task-1", "session-completed-standalone", agentruntime.RuntimeStandalone, models.ExecutorRunningStatusComplete) + seedLiveStandaloneRow(t, repo, "task-1", "session-failed-standalone", agentruntime.RuntimeStandalone, models.ExecutorRunningStatusFailed) + seedLiveStandaloneRow(t, repo, "task-1", "session-live-docker", agentruntime.RuntimeDocker, models.ExecutorRunningStatusRunning) + seedLiveStandaloneRow(t, repo, "task-1", "session-live-ssh", agentruntime.RuntimeSSH, models.ExecutorRunningStatusRunning) + + rows, err := repo.ListExecutorsRunningLiveStandalone(ctx) + if err != nil { + t.Fatalf("ListExecutorsRunningLiveStandalone: %v", err) + } + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1: %#v", len(rows), rows) + } + if rows[0].SessionID != "session-live-standalone" { + t.Fatalf("SessionID = %q, want %q", rows[0].SessionID, "session-live-standalone") + } + if rows[0].Runtime != agentruntime.RuntimeStandalone { + t.Fatalf("Runtime = %q, want %q", rows[0].Runtime, agentruntime.RuntimeStandalone) + } +} + +func seedLiveStandaloneRow( + t *testing.T, + repo *Repository, + taskID, sessionID string, + runtime agentruntime.Runtime, + status string, +) { + t.Helper() + ctx := context.Background() + + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: sessionID, TaskID: taskID, State: models.TaskSessionStateRunning}); err != nil { + t.Fatalf("CreateTaskSession(%s): %v", sessionID, err) + } + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: sessionID, + SessionID: sessionID, + TaskID: taskID, + ExecutorID: "executor-1", + Runtime: runtime, + Status: status, + AgentExecutionID: "exec-" + sessionID, + }); err != nil { + t.Fatalf("UpsertExecutorRunning(%s): %v", sessionID, err) + } +} diff --git a/apps/backend/internal/task/service/service_kubernetes_restart_cleanup_test.go b/apps/backend/internal/task/service/service_kubernetes_restart_cleanup_test.go index cdbca0c5a9..ceeaa22315 100644 --- a/apps/backend/internal/task/service/service_kubernetes_restart_cleanup_test.go +++ b/apps/backend/internal/task/service/service_kubernetes_restart_cleanup_test.go @@ -215,7 +215,7 @@ func (b *persistedKubernetesCleanupBackend) StopInstance( b.pvcDeleted = true return nil } -func (*persistedKubernetesCleanupBackend) RecoverInstances(context.Context) ([]*lifecycle.ExecutorInstance, error) { +func (*persistedKubernetesCleanupBackend) RecoverInstances(context.Context, []*models.ExecutorRunning) ([]*lifecycle.ExecutorInstance, error) { return nil, nil } func (*persistedKubernetesCleanupBackend) GetInteractiveRunner() *process.InteractiveRunner { diff --git a/apps/web/components/settings/system/feature-toggle-card.test.tsx b/apps/web/components/settings/system/feature-toggle-card.test.tsx index 83406f7a91..608a7be9ea 100644 --- a/apps/web/components/settings/system/feature-toggle-card.test.tsx +++ b/apps/web/components/settings/system/feature-toggle-card.test.tsx @@ -1,5 +1,6 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "@kandev/ui/tooltip"; import type { RuntimeFlagState } from "@/lib/types/runtime-flags"; import { FeatureToggleCard } from "./feature-toggle-card"; @@ -27,6 +28,8 @@ vi.mock("@kandev/ui/switch", () => ({ afterEach(cleanup); +const TOGGLE_LABEL = "Toggle Office mode"; + describe("FeatureToggleCard", () => { it("shows risk copy as supporting text instead of a warning alert", () => { render( @@ -56,7 +59,7 @@ describe("FeatureToggleCard", () => { />, ); - fireEvent.click(screen.getByLabelText("Toggle Office mode")); + fireEvent.click(screen.getByLabelText(TOGGLE_LABEL)); expect(onChange).toHaveBeenCalledWith(true); }); @@ -89,7 +92,7 @@ describe("FeatureToggleCard", () => { />, ); - expect(screen.getByLabelText("Toggle Office mode")).toHaveProperty("disabled", true); + expect(screen.getByLabelText(TOGGLE_LABEL)).toHaveProperty("disabled", true); expect(screen.getByRole("button", { name: /use default/i })).toHaveProperty("disabled", true); expect(screen.getByText("Controlled by launch environment")).not.toBeNull(); }); @@ -106,7 +109,7 @@ describe("FeatureToggleCard", () => { />, ); - expect(screen.getByLabelText("Toggle Office mode")).toHaveProperty("disabled", true); + expect(screen.getByLabelText(TOGGLE_LABEL)).toHaveProperty("disabled", true); expect(screen.getByRole("button", { name: /use default/i })).toHaveProperty("disabled", true); }); @@ -125,6 +128,49 @@ describe("FeatureToggleCard", () => { }); }); +describe("FeatureToggleCard - unavailable state", () => { + it("disables the switch and shows the reason when the flag is unavailable on this host", () => { + const onChange = vi.fn(); + render( + + undefined} + /> + , + ); + + expect(screen.getByLabelText(TOGGLE_LABEL)).toHaveProperty("disabled", true); + expect(screen.getByText("Unavailable")).not.toBeNull(); + expect( + screen.getByText("This feature isn't supported on this operating system."), + ).not.toBeNull(); + }); + + it("falls back to a generic unavailable message for an unrecognized reason code", () => { + render( + + undefined} + onReset={() => undefined} + /> + , + ); + + expect( + screen.getByText("This feature can't be enabled on this install right now."), + ).not.toBeNull(); + }); +}); + function flagState(overrides: Partial = {}): RuntimeFlagState { return { key: "features.office", diff --git a/apps/web/components/settings/system/feature-toggle-card.tsx b/apps/web/components/settings/system/feature-toggle-card.tsx index c172339537..278fa6a2e9 100644 --- a/apps/web/components/settings/system/feature-toggle-card.tsx +++ b/apps/web/components/settings/system/feature-toggle-card.tsx @@ -5,7 +5,8 @@ import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Switch } from "@kandev/ui/switch"; -import { IconFlask, IconLock, IconRefresh } from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { IconAlertTriangle, IconFlask, IconLock, IconRefresh } from "@tabler/icons-react"; import type { TFunction } from "i18next"; import type { RuntimeFlagState } from "@/lib/types/runtime-flags"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -27,7 +28,8 @@ export function FeatureToggleCard({ onReset, }: FeatureToggleCardProps) { const { t } = useTranslation(); - const disabled = saving || flag.env_locked || !flag.mutable; + const unavailable = flag.availability?.available === false; + const disabled = saving || flag.env_locked || !flag.mutable || unavailable; return ( @@ -41,19 +43,18 @@ export function FeatureToggleCard({

{flag.description}

-
{flag.risk_description && (

{flag.risk_description}

)} + {unavailable && }