dcfec0d23c
* refactor(backend): classify errors via sentinels instead of substring matches
Introduce package-local sentinel errors in the github, task repo, agent
lifecycle, and office/skills packages. Producers wrap with fmt.Errorf("%w: ...")
so callers can classify via errors.Is/errors.As instead of brittle
strings.Contains(err.Error(), ...) checks that break on library upgrades.
Sentinels added (or relocated to errors.go):
- github: ErrInvalidToken (new); ErrInvalidPRURL, ErrTaskNotFound, ErrSelfApprove
moved from service.go.
- task/repository/sqlite: ErrTaskNotFound, ErrOfficeSessionRaceConflict.
- lifecycle: ErrAgentAlreadyRunning, ErrAgentReported.
- office/skills: ErrSkillNotFound, ErrSkillFileNotFound.
Consumers updated to errors.Is/errors.As across orchestrator, task/handlers,
github, and office/skills. AssociateExistingPRByURL switches %v -> %w so the
inner parsePRURL error stays in the chain. process_handlers now uses
errors.Is(err, syscall.ECONNREFUSED) and lifecycle.ErrNoExecutionForSession
plus a small isTimeoutError helper backed by errors.As.
A new taskDeleterAdapter in cmd/kandev/turn_adapters.go translates
taskrepo.ErrTaskNotFound to github.ErrTaskNotFound at the wiring boundary,
avoiding a layering violation between the github and task packages.
The one remaining substring check, in CreateTaskSession, is encapsulated
inside the repo and converts the SQLite UNIQUE-violation message into the
typed ErrOfficeSessionRaceConflict sentinel so callers never see the driver
string.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(backend): add TestErrorsAreClassifiable for refactored packages
Pin the new sentinel-based contract for each refactored package. Each test
exercises the producer paths and asserts errors.Is reaches the sentinel
through the wrap chain, and (where applicable) confirms untyped lookalike
strings no longer classify.
- github: isTaskNotFound, AssociateExistingPRByURL/ErrInvalidPRURL,
ConfigureToken/ErrInvalidToken (using a noop secret manager).
- task/repository/sqlite: GetTask, DeleteTask, UpdateTaskState wrap
ErrTaskNotFound.
- task/handlers: isNotFound + isAgentReportedError follow the typed chain.
- orchestrator: each isXError classifies exact + wrapped sentinels, rejects
untyped lookalikes.
- office/skills: readUserHomeSkillInventoryFile wraps ErrSkillFileNotFound;
ErrSkillNotFound survives fmt.Errorf("%w", ...).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(github): drop network-hitting subtest from errors_test
The "ConfigureToken wraps ErrInvalidToken when validation fails" subtest
constructed a real PATClient and called GetAuthenticatedUser, which makes
a live HTTP request to api.github.com. That's flaky in CI / offline dev
and violates the unit-test boundary (caught in review).
Replace with a sentinel-survives-wrap-pattern check that locks in the
exact %w/%w shape used by ConfigureToken, plus an assertion that the
inner cause stays reachable through errors.Is. The full ConfigureToken
flow stays covered by service_token_test.go and integration tests.
Remove the now-unused noopSecretManager stub type.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(task): restore "timed out" UX for plain-fmt.Errorf timeout producers
The original createPromptErrorMessage rendered the friendly "Request timed
out…" hint via strings.Contains(err.Error(), "timeout"). The refactor
replaced it with errors.Is(context.DeadlineExceeded) + a net.Error
Timeout() helper — strictly typed. But several upstream producers along
the prompt path (service.WaitForSessionReady, agent-stream connect waits
in lifecycle/session.go and manager_interaction.go, agentctl health waits
in agentctl/client.go and launcher.go, the orchestrator's own
waitForSessionReady) return plain fmt.Errorf("timeout …") with no typed
Timeout() method, so the typed check missed them and the user saw the
generic "Failed to send message to agent" instead.
Add a substring fallback inside isTimeoutError as a UX safety net while
keeping the primary typed paths intact. Lock the contract with table
tests covering all three classes (net.Error timeout, plain string,
context.DeadlineExceeded) so any future trim of the fallback breaks
loudly. Migrating each upstream producer to a typed sentinel is tracked
as out-of-scope follow-up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(backend): tighten classification tests to exercise typed-sentinel contracts
Addresses bot-review feedback on PR #1057:
- task/handlers/errors_test.go: cover all four not-found sentinels
(taskrepo.ErrTaskNotFound, service.ErrDocumentNotFound,
service.ErrTaskPlanNotFound, service.ErrRevisionNotFound) in
TestErrorsAreClassifiable via a table-driven loop, instead of only
one. Drop the spurious Deadline() *time.Time method on timeoutNetErr
— it isn't part of net.Error and the existing `var _ net.Error =
timeoutNetErr{}` line already pins the interface.
- orchestrator/scheduler/scheduler_test.go: testTaskRepository was
still returning the local scheduler.ErrTaskNotFound, which doesn't
satisfy the production processTasks check (now errors.Is on
taskrepo.ErrTaskNotFound). Mirror the real sqlite repo's wrap so the
mock and prod paths agree. The local scheduler.ErrTaskNotFound is
truly dead after that, so drop it from scheduler.go.
- office/skills: the wrap-pattern subtest in errors_test.go was only
testing fmt.Errorf, not production code. Move the contract assertion
to service_test.go (external skills_test package) where it drives
the real GetSkillFromConfig lookup path against an empty in-memory
repository.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(backend): drop redundant context.DeadlineExceeded check and fix doc grammar
Addresses bot-review feedback on PR #1057:
- task/handlers/message_handlers.go::createPromptErrorMessage: the
explicit errors.Is(promptErr, context.DeadlineExceeded) is redundant
with isTimeoutError, which already matches via errors.As against
Timeout() — context.DeadlineExceeded implements Timeout()==true.
Inline comment documents the consolidation.
- github/errors.go: fix grammar in the ErrTaskNotFound doc comment
("is the sentinel cleanup paths check for to distinguish" →
"is the sentinel that cleanup paths check to distinguish").
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
225 lines
7.1 KiB
Go
225 lines
7.1 KiB
Go
// Package lifecycle manages agent execution lifecycles including tracking,
|
|
// state transitions, and cleanup.
|
|
package lifecycle
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
// ErrExecutionNotFound is returned when an execution doesn't exist in the store.
|
|
var ErrExecutionNotFound = errors.New("execution not found")
|
|
|
|
// ErrExecutionAlreadyExistsForSession is returned by Add when the session
|
|
// already maps to a different execution. The previous behavior was to silently
|
|
// overwrite the bySession index, which orphaned the prior execution: the
|
|
// agentctl/subprocess kept running but was unreachable via GetBySessionID, so
|
|
// nothing ever cleaned it up. Callers must Remove the prior execution before
|
|
// adding a replacement.
|
|
var ErrExecutionAlreadyExistsForSession = errors.New("execution already exists for session")
|
|
|
|
// ErrAgentAlreadyRunning is returned by LaunchAgent when an agent execution is
|
|
// already tracked for the requested session. The error fires both when the
|
|
// execution is live (a concurrent caller raced us) and when it is stale (a
|
|
// prior registration whose process never started or exited without cleanup);
|
|
// callers must probe IsAgentRunningForSession to decide. Wrapped at producer
|
|
// sites with %w so callers can use errors.Is rather than string-match the
|
|
// surrounding context.
|
|
var ErrAgentAlreadyRunning = errors.New("session already has an agent running")
|
|
|
|
// ErrAgentReported is returned when the agent subprocess publishes an error
|
|
// event during a prompt turn. Distinguished from infrastructure errors
|
|
// (network, timeout) so callers can suppress duplicate error-message creation
|
|
// — the agent failure path already records the error in the session.
|
|
var ErrAgentReported = errors.New("agent error")
|
|
|
|
// ExecutionStore provides thread-safe storage and retrieval of agent executions.
|
|
// It maintains three indexes for efficient lookup by execution ID, session ID, and container ID.
|
|
type ExecutionStore struct {
|
|
executions map[string]*AgentExecution
|
|
bySession map[string]string // sessionID -> executionID
|
|
byContainer map[string]string // containerID -> executionID
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewExecutionStore creates a new ExecutionStore with initialized maps.
|
|
func NewExecutionStore() *ExecutionStore {
|
|
return &ExecutionStore{
|
|
executions: make(map[string]*AgentExecution),
|
|
bySession: make(map[string]string),
|
|
byContainer: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
// Add adds an agent execution to all tracking maps.
|
|
// The execution must have a valid ID. SessionID and ContainerID are optional
|
|
// but will be indexed if present.
|
|
//
|
|
// Returns ErrExecutionAlreadyExistsForSession if the session is already mapped
|
|
// to a different execution. Callers must explicitly Remove the prior execution
|
|
// before adding a replacement — otherwise the prior subprocess is orphaned.
|
|
func (s *ExecutionStore) Add(execution *AgentExecution) error {
|
|
if execution == nil || execution.ID == "" {
|
|
return fmt.Errorf("execution and execution.ID are required")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if execution.SessionID != "" {
|
|
if existingID, exists := s.bySession[execution.SessionID]; exists && existingID != execution.ID {
|
|
return fmt.Errorf("%w: session=%s existing=%s new=%s",
|
|
ErrExecutionAlreadyExistsForSession,
|
|
execution.SessionID, existingID, execution.ID)
|
|
}
|
|
}
|
|
|
|
s.executions[execution.ID] = execution
|
|
|
|
if execution.SessionID != "" {
|
|
s.bySession[execution.SessionID] = execution.ID
|
|
}
|
|
|
|
if execution.ContainerID != "" {
|
|
s.byContainer[execution.ContainerID] = execution.ID
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Remove removes an agent execution from all tracking maps.
|
|
func (s *ExecutionStore) Remove(executionID string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
execution, exists := s.executions[executionID]
|
|
if !exists {
|
|
return
|
|
}
|
|
|
|
// Remove from secondary indexes
|
|
if execution.SessionID != "" {
|
|
delete(s.bySession, execution.SessionID)
|
|
}
|
|
if execution.ContainerID != "" {
|
|
delete(s.byContainer, execution.ContainerID)
|
|
}
|
|
|
|
// Remove from primary map
|
|
delete(s.executions, executionID)
|
|
}
|
|
|
|
// Get returns an agent execution by its ID.
|
|
func (s *ExecutionStore) Get(executionID string) (*AgentExecution, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
execution, exists := s.executions[executionID]
|
|
return execution, exists
|
|
}
|
|
|
|
// GetBySessionID returns the agent execution associated with a session ID.
|
|
func (s *ExecutionStore) GetBySessionID(sessionID string) (*AgentExecution, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
executionID, exists := s.bySession[sessionID]
|
|
if !exists {
|
|
return nil, false
|
|
}
|
|
|
|
execution, exists := s.executions[executionID]
|
|
return execution, exists
|
|
}
|
|
|
|
// GetByTaskEnvironmentID returns any execution associated with a task environment ID.
|
|
func (s *ExecutionStore) GetByTaskEnvironmentID(taskEnvironmentID string) (*AgentExecution, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
for _, execution := range s.executions {
|
|
if execution.TaskEnvironmentID == taskEnvironmentID {
|
|
return execution, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// GetByContainerID returns the agent execution associated with a container ID.
|
|
func (s *ExecutionStore) GetByContainerID(containerID string) (*AgentExecution, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
executionID, exists := s.byContainer[containerID]
|
|
if !exists {
|
|
return nil, false
|
|
}
|
|
|
|
execution, exists := s.executions[executionID]
|
|
return execution, exists
|
|
}
|
|
|
|
// List returns all tracked agent executions.
|
|
func (s *ExecutionStore) List() []*AgentExecution {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
result := make([]*AgentExecution, 0, len(s.executions))
|
|
for _, execution := range s.executions {
|
|
result = append(result, execution)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// UpdateStatus updates the status of an agent execution.
|
|
func (s *ExecutionStore) UpdateStatus(executionID string, status v1.AgentStatus) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if execution, exists := s.executions[executionID]; exists {
|
|
execution.Status = status
|
|
}
|
|
}
|
|
|
|
// UpdateError updates the error message of an agent execution and sets its status to failed.
|
|
func (s *ExecutionStore) UpdateError(executionID string, errorMsg string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if execution, exists := s.executions[executionID]; exists {
|
|
execution.ErrorMessage = errorMsg
|
|
execution.Status = v1.AgentStatusFailed
|
|
}
|
|
}
|
|
|
|
// WithLock executes a function with the store lock held, providing access to the execution.
|
|
// Returns ErrExecutionNotFound if the execution doesn't exist.
|
|
// The function should be fast to avoid blocking other operations.
|
|
func (s *ExecutionStore) WithLock(executionID string, fn func(execution *AgentExecution)) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
execution, exists := s.executions[executionID]
|
|
if !exists {
|
|
return ErrExecutionNotFound
|
|
}
|
|
fn(execution)
|
|
return nil
|
|
}
|
|
|
|
// WithRLock executes a function with the store read lock held, providing access to the execution.
|
|
// Returns ErrExecutionNotFound if the execution doesn't exist.
|
|
func (s *ExecutionStore) WithRLock(executionID string, fn func(execution *AgentExecution)) error {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
execution, exists := s.executions[executionID]
|
|
if !exists {
|
|
return ErrExecutionNotFound
|
|
}
|
|
fn(execution)
|
|
return nil
|
|
}
|