From d99092e09c3ce44eba749563632a27b06d6c540e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 21 Aug 2026 16:39:47 +0100 Subject: [PATCH] fix: add durable task launch failure recovery (#2832) --- .../agent/runtime/lifecycle/env_preparer.go | 30 +- .../lifecycle/env_preparer_worktree.go | 37 +- .../agent/runtime/lifecycle/manager_launch.go | 2 + .../task_repository_identity_test.go | 34 + .../internal/agent/runtime/lifecycle/types.go | 3 + apps/backend/internal/backendapp/adapters.go | 49 +- apps/backend/internal/backendapp/gateway.go | 46 +- .../internal/backendapp/orchestrator.go | 14 + apps/backend/internal/common/gitref/gitref.go | 32 +- .../internal/common/gitref/gitref_test.go | 27 + .../common/netutil/addr_in_use_windows.go | 6 +- .../orchestrator/event_handlers_automation.go | 1 + .../orchestrator/event_handlers_github.go | 3 + .../orchestrator/event_handlers_stall.go | 3 +- .../orchestrator/event_handlers_streaming.go | 5 + .../event_handlers_streaming_test.go | 39 + .../orchestrator/event_handlers_workflow.go | 6 + .../orchestrator/executor/executor.go | 74 +- .../executor/executor_environment_reuse.go | 1 + .../orchestrator/executor/executor_execute.go | 99 ++- ...utor_launch_failure_classification_test.go | 76 ++ .../orchestrator/executor/executor_resume.go | 39 +- .../executor/executor_resume_test.go | 19 + .../orchestrator/executor/launch_failure.go | 96 +++ .../executor/task_repository_identity_test.go | 29 + .../task_repository_self_heal_test.go | 68 ++ .../orchestrator/handlers/handlers.go | 37 + apps/backend/internal/orchestrator/service.go | 338 +------- .../internal/orchestrator/session_ensure.go | 18 +- .../internal/orchestrator/session_launch.go | 16 + .../orchestrator/session_launch_test.go | 20 + .../orchestrator/task_launch_failure_test.go | 772 +----------------- .../orchestrator/task_launch_pr_gate.go | 350 ++++++++ .../orchestrator/task_launch_pr_gate_test.go | 302 +++++++ .../orchestrator/task_launch_recovery.go | 628 ++++++++++++++ .../orchestrator/task_launch_recovery_test.go | 420 ++++++++++ .../orchestrator/task_launch_workflow.go | 39 + .../internal/orchestrator/task_operations.go | 41 +- .../orchestrator/task_operations_test.go | 114 ++- .../internal/task/models/launch_errors.go | 212 +++++ .../task/models/launch_errors_test.go | 110 +++ apps/backend/internal/task/models/models.go | 11 +- .../sqlite/metadata_launch_error_cas.go | 176 ++++ .../repository/sqlite/postgres_schema_test.go | 6 +- .../repository/sqlite/repository_entity.go | 23 + .../sqlite/repository_entity_test.go | 25 + .../task/repository/sqlite/session.go | 57 +- .../sqlite/session_launch_error_cas_test.go | 90 ++ .../session_launch_error_postgres_test.go | 63 ++ .../internal/task/repository/sqlite/task.go | 37 + .../sqlite/task_launch_error_cas_test.go | 96 +++ .../sqlite/task_launch_error_postgres_test.go | 39 + .../service/service_status_summary_rebuild.go | 32 +- .../service_status_summary_rebuild_test.go | 20 +- .../internal/task/service/service_workflow.go | 21 +- .../task/service/service_workflow_test.go | 58 ++ .../internal/task/statussummary/model.go | 38 +- .../internal/task/statussummary/model_test.go | 74 ++ .../internal/task/statussummary/projector.go | 53 +- .../task/statussummary/projector_derive.go | 37 +- .../task/statussummary/projector_events.go | 61 +- .../task/statussummary/projector_helpers.go | 44 +- .../projector_task_error_test.go | 125 +++ .../internal/task/statussummary/rebuild.go | 9 + apps/backend/internal/worktree/errors.go | 12 +- apps/backend/internal/worktree/errors_test.go | 7 + apps/backend/internal/worktree/manager_git.go | 20 + .../internal/worktree/manager_lifecycle.go | 137 +++- .../worktree/manager_live_default_test.go | 165 ++++ .../backend/internal/worktree/manager_test.go | 6 + apps/backend/pkg/websocket/actions.go | 1 + .../app/office/tasks/[id]/map-office-task.ts | 11 +- apps/web/app/office/tasks/[id]/page.tsx | 68 +- apps/web/app/office/tasks/[id]/types.ts | 9 + .../components/task/base-branch-picker.tsx | 106 +-- .../task/branch-picker-list.test.tsx | 29 + .../components/task/branch-picker-list.tsx | 116 +++ .../task/mobile/mobile-picker-sheet.tsx | 10 +- .../task/simple/OfficeSimplePane.tsx | 17 +- .../task/simple/chat-activity-tabs.tsx | 3 + .../task/simple/chat-entries.test.ts | 25 + .../components/task/simple/chat-entries.ts | 18 + .../components/run-error-entry.test.tsx | 49 ++ .../simple/components/run-error-entry.tsx | 141 +++- .../components/task-chat-launch-error.tsx | 43 + .../task-launch-error-entry.test.tsx | 215 +++++ .../components/task-launch-error-entry.tsx | 220 +++++ .../components/task/simple/task-chat.test.tsx | 65 ++ apps/web/components/task/simple/task-chat.tsx | 46 +- apps/web/components/task/task-chat-panel.tsx | 17 + .../task/task-launch-branch-picker.tsx | 94 +++ .../task/task-launch-error-context.test.tsx | 69 ++ .../task/task-launch-error-context.tsx | 74 ++ apps/web/components/task/task-layout.tsx | 22 + apps/web/components/task/task-page-inner.tsx | 13 +- apps/web/e2e/fixtures/test-base.ts | 42 +- apps/web/e2e/helpers/api-client.ts | 1 + .../mobile-pr-watcher-missing-branch.spec.ts | 44 +- .../pr/pr-watcher-missing-branch.spec.ts | 83 +- .../task/launch-failure-recovery.spec.ts | 279 +++++++ .../mobile-launch-failure-recovery.spec.ts | 142 ++++ .../task/use-task-status-summary.test.ts | 33 + .../domains/task/use-task-status-summary.ts | 49 ++ apps/web/hooks/use-session-failure-toast.ts | 7 +- .../lib/services/session-launch-service.ts | 7 +- apps/web/lib/session-last-agent-error.test.ts | 19 + apps/web/lib/session-last-agent-error.ts | 47 +- .../state/slices/office/office-task-type.ts | 4 + apps/web/lib/state/slices/office/types.ts | 1 + apps/web/lib/state/slices/ui/types.ts | 2 + apps/web/lib/task-status-summary.test.ts | 37 +- apps/web/lib/task-status-summary.ts | 16 +- apps/web/lib/types/task-launch-error.ts | 28 + apps/web/lib/types/task-status-summary.ts | 18 +- .../web/lib/ws/handlers/agent-session.test.ts | 29 + apps/web/lib/ws/handlers/agent-session.ts | 19 +- apps/web/src/locales/en/task.json | 17 +- apps/web/src/locales/pseudo/task.json | 17 +- apps/web/src/locales/pt-pt/task.json | 17 +- apps/web/src/locales/zh-cn/task.json | 17 +- apps/web/src/locales/zh-hk/task.json | 17 +- apps/web/src/locales/zh-tw/task.json | 17 +- .../task-launch-failure-recovery/plan.md | 334 ++++++++ .../task-01-failure-taxonomy-contracts.md | 40 + .../task-02-gitref-default-hardening.md | 37 + .../task-03-worktree-live-default-fallback.md | 38 + .../task-04-launch-failure-classification.md | 45 + .../task-05-pr-review-autostart-gating.md | 51 ++ .../task-06-recovery-actions-ws.md | 74 ++ .../task-07-status-summary-projection.md | 48 ++ ...8-frontend-failure-surface-and-recovery.md | 55 ++ .../task-09-e2e-and-i18n.md | 50 ++ .../task-10-task-base-self-heal.md | 52 ++ ...task-11-responsive-launch-error-surface.md | 50 ++ .../task-12-remove-legacy-launch-guidance.md | 45 + docs/specs/INDEX.md | 1 + .../platform/bounded-task-status-delivery.md | 21 +- .../task-launch-failure-recovery/spec.md | 275 +++++++ 138 files changed, 7881 insertions(+), 1622 deletions(-) create mode 100644 apps/backend/internal/agent/runtime/lifecycle/task_repository_identity_test.go create mode 100644 apps/backend/internal/orchestrator/executor/executor_launch_failure_classification_test.go create mode 100644 apps/backend/internal/orchestrator/executor/launch_failure.go create mode 100644 apps/backend/internal/orchestrator/executor/task_repository_identity_test.go create mode 100644 apps/backend/internal/orchestrator/executor/task_repository_self_heal_test.go create mode 100644 apps/backend/internal/orchestrator/task_launch_pr_gate.go create mode 100644 apps/backend/internal/orchestrator/task_launch_pr_gate_test.go create mode 100644 apps/backend/internal/orchestrator/task_launch_recovery.go create mode 100644 apps/backend/internal/orchestrator/task_launch_recovery_test.go create mode 100644 apps/backend/internal/orchestrator/task_launch_workflow.go create mode 100644 apps/backend/internal/task/models/launch_errors.go create mode 100644 apps/backend/internal/task/models/launch_errors_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/metadata_launch_error_cas.go create mode 100644 apps/backend/internal/task/repository/sqlite/session_launch_error_cas_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/session_launch_error_postgres_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/task_launch_error_cas_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/task_launch_error_postgres_test.go create mode 100644 apps/backend/internal/task/statussummary/projector_task_error_test.go create mode 100644 apps/backend/internal/worktree/manager_live_default_test.go create mode 100644 apps/web/components/task/branch-picker-list.test.tsx create mode 100644 apps/web/components/task/branch-picker-list.tsx create mode 100644 apps/web/components/task/simple/components/run-error-entry.test.tsx create mode 100644 apps/web/components/task/simple/components/task-chat-launch-error.tsx create mode 100644 apps/web/components/task/simple/components/task-launch-error-entry.test.tsx create mode 100644 apps/web/components/task/simple/components/task-launch-error-entry.tsx create mode 100644 apps/web/components/task/task-launch-branch-picker.tsx create mode 100644 apps/web/components/task/task-launch-error-context.test.tsx create mode 100644 apps/web/components/task/task-launch-error-context.tsx create mode 100644 apps/web/e2e/tests/task/launch-failure-recovery.spec.ts create mode 100644 apps/web/e2e/tests/task/mobile-launch-failure-recovery.spec.ts create mode 100644 apps/web/hooks/domains/task/use-task-status-summary.test.ts create mode 100644 apps/web/hooks/domains/task/use-task-status-summary.ts create mode 100644 apps/web/lib/types/task-launch-error.ts create mode 100644 docs/plans/task-launch-failure-recovery/plan.md create mode 100644 docs/plans/task-launch-failure-recovery/task-01-failure-taxonomy-contracts.md create mode 100644 docs/plans/task-launch-failure-recovery/task-02-gitref-default-hardening.md create mode 100644 docs/plans/task-launch-failure-recovery/task-03-worktree-live-default-fallback.md create mode 100644 docs/plans/task-launch-failure-recovery/task-04-launch-failure-classification.md create mode 100644 docs/plans/task-launch-failure-recovery/task-05-pr-review-autostart-gating.md create mode 100644 docs/plans/task-launch-failure-recovery/task-06-recovery-actions-ws.md create mode 100644 docs/plans/task-launch-failure-recovery/task-07-status-summary-projection.md create mode 100644 docs/plans/task-launch-failure-recovery/task-08-frontend-failure-surface-and-recovery.md create mode 100644 docs/plans/task-launch-failure-recovery/task-09-e2e-and-i18n.md create mode 100644 docs/plans/task-launch-failure-recovery/task-10-task-base-self-heal.md create mode 100644 docs/plans/task-launch-failure-recovery/task-11-responsive-launch-error-surface.md create mode 100644 docs/plans/task-launch-failure-recovery/task-12-remove-legacy-launch-guidance.md create mode 100644 docs/specs/task-launch-failure-recovery/spec.md diff --git a/apps/backend/internal/agent/runtime/lifecycle/env_preparer.go b/apps/backend/internal/agent/runtime/lifecycle/env_preparer.go index 5a2933c07d..e2136a97fd 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/env_preparer.go +++ b/apps/backend/internal/agent/runtime/lifecycle/env_preparer.go @@ -26,6 +26,7 @@ const ( // carried at the top level. When EnvPrepareRequest.Repositories is non-empty, // each entry produces one prepared worktree under the shared TaskDirName. type RepoPrepareSpec struct { + TaskRepositoryID string RepositoryID string RepositoryPath string RepoName string @@ -64,6 +65,7 @@ type EnvPrepareRequest struct { WorkspacePath string RepositoryPath string RepositoryID string + TaskRepositoryID string UseWorktree bool SetupScript string RepoSetupScript string // Repository-level setup script (e.g. "make install") @@ -111,6 +113,7 @@ func (r *EnvPrepareRequest) RepoSpecs() []RepoPrepareSpec { return nil } return []RepoPrepareSpec{{ + TaskRepositoryID: r.TaskRepositoryID, RepositoryID: r.RepositoryID, RepositoryPath: r.RepositoryPath, RepoName: r.RepoName, @@ -150,13 +153,17 @@ type PrepareStep struct { // Populated by preparers that handle multi-repo launches; each entry corresponds // to one RepoPrepareSpec from the request. type RepoWorktreeResult struct { - RepositoryID string `json:"repository_id"` - BranchSlug string `json:"branch_slug,omitempty"` - WorktreeID string `json:"worktree_id,omitempty"` - WorktreeBranch string `json:"worktree_branch,omitempty"` - WorktreePath string `json:"worktree_path,omitempty"` - MainRepoGitDir string `json:"main_repo_git_dir,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` + TaskRepositoryID string `json:"task_repository_id,omitempty"` + RepositoryID string `json:"repository_id"` + BranchSlug string `json:"branch_slug,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + MainRepoGitDir string `json:"main_repo_git_dir,omitempty"` + RequestedBaseBranch string `json:"requested_base_branch,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + BaseBranchFallbackWarning string `json:"base_branch_fallback_warning,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` } // EnvPrepareResult contains the result of environment preparation. @@ -169,9 +176,12 @@ type EnvPrepareResult struct { // Worktree fields (populated when worktree preparer runs). // Legacy single-worktree fields; for multi-repo results they mirror Worktrees[0]. - WorktreeID string `json:"worktree_id,omitempty"` - WorktreeBranch string `json:"worktree_branch,omitempty"` - MainRepoGitDir string `json:"main_repo_git_dir,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` + MainRepoGitDir string `json:"main_repo_git_dir,omitempty"` + RequestedBaseBranch string `json:"requested_base_branch,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + BaseBranchFallbackWarning string `json:"base_branch_fallback_warning,omitempty"` // Worktrees is the per-repository outcome list when the preparer ran in // multi-repo mode. Empty for single-repo or repo-less results. diff --git a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go index 3ce929ca82..caf8c7f1e7 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go +++ b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go @@ -133,13 +133,16 @@ func (p *WorktreePreparer) Prepare(ctx context.Context, req *EnvPrepareRequest, } return &EnvPrepareResult{ - Success: true, - Steps: steps, - WorkspacePath: workspacePath, - Duration: time.Since(start), - WorktreeID: wt.ID, - WorktreeBranch: wt.Branch, - MainRepoGitDir: mainRepoGitDir, + Success: true, + Steps: steps, + WorkspacePath: workspacePath, + Duration: time.Since(start), + WorktreeID: wt.ID, + WorktreeBranch: wt.Branch, + MainRepoGitDir: mainRepoGitDir, + RequestedBaseBranch: req.BaseBranch, + BaseBranch: wt.BaseBranch, + BaseBranchFallbackWarning: wt.BaseBranchFallbackWarning, }, nil } @@ -414,12 +417,16 @@ func (p *WorktreePreparer) prepareMultiRepo( createdIDs = append(createdIDs, wt.ID) } worktrees = append(worktrees, RepoWorktreeResult{ - RepositoryID: spec.RepositoryID, - BranchSlug: repoBranchIdentitySlug(spec), - WorktreeID: wt.ID, - WorktreeBranch: wt.Branch, - WorktreePath: wt.Path, - MainRepoGitDir: filepath.Join(spec.RepositoryPath, ".git"), + TaskRepositoryID: spec.TaskRepositoryID, + RepositoryID: spec.RepositoryID, + BranchSlug: repoBranchIdentitySlug(spec), + WorktreeID: wt.ID, + WorktreeBranch: wt.Branch, + WorktreePath: wt.Path, + MainRepoGitDir: filepath.Join(spec.RepositoryPath, ".git"), + RequestedBaseBranch: spec.BaseBranch, + BaseBranch: wt.BaseBranch, + BaseBranchFallbackWarning: wt.BaseBranchFallbackWarning, }) } @@ -443,6 +450,9 @@ func (p *WorktreePreparer) prepareMultiRepo( res.WorktreeID = worktrees[0].WorktreeID res.WorktreeBranch = worktrees[0].WorktreeBranch res.MainRepoGitDir = worktrees[0].MainRepoGitDir + res.RequestedBaseBranch = worktrees[0].RequestedBaseBranch + res.BaseBranch = worktrees[0].BaseBranch + res.BaseBranchFallbackWarning = worktrees[0].BaseBranchFallbackWarning } return res, nil } @@ -489,6 +499,7 @@ func (p *WorktreePreparer) prepareOneRepo( // Sync (optional) + Create — reuses single-repo helper by translating spec→req. subReq := *req subReq.RepositoryID = spec.RepositoryID + subReq.TaskRepositoryID = spec.TaskRepositoryID subReq.RepositoryPath = spec.RepositoryPath subReq.RepoName = spec.RepoName subReq.BaseBranch = spec.BaseBranch diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index f0b991133c..e5bad9bcdc 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -913,6 +913,7 @@ func buildEnvPrepareRequest(req *LaunchRequest, workspacePath string, execName e WorkspacePath: workspacePath, RepositoryPath: req.RepositoryPath, RepositoryID: req.RepositoryID, + TaskRepositoryID: req.TaskRepositoryID, UseWorktree: req.UseWorktree, WorktreeID: req.WorktreeID, SetupScript: req.SetupScript, @@ -946,6 +947,7 @@ func buildEnvPrepareRequest(req *LaunchRequest, workspacePath string, execName e setup = repoSetupScript } specs = append(specs, RepoPrepareSpec{ + TaskRepositoryID: r.TaskRepositoryID, RepositoryID: r.RepositoryID, RepositoryPath: r.RepositoryPath, RepoName: r.RepoName, diff --git a/apps/backend/internal/agent/runtime/lifecycle/task_repository_identity_test.go b/apps/backend/internal/agent/runtime/lifecycle/task_repository_identity_test.go new file mode 100644 index 0000000000..5cf3e6fd86 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/task_repository_identity_test.go @@ -0,0 +1,34 @@ +package lifecycle + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRepoSpecsCarriesTaskRepositoryIDThroughLegacySynthesis(t *testing.T) { + req := LaunchRequest{ + RepositoryID: "repository-1", + TaskRepositoryID: "task-repository-1", + RepositoryPath: "/repo", + } + + specs := req.RepoSpecs() + require.Len(t, specs, 1) + require.Equal(t, "task-repository-1", specs[0].TaskRepositoryID) +} + +func TestBuildEnvPrepareRequestCarriesTaskRepositoryIDs(t *testing.T) { + req := &LaunchRequest{ + TaskID: "task-1", + Repositories: []RepoLaunchSpec{ + {RepositoryID: "repository-1", TaskRepositoryID: "task-repository-1", RepositoryPath: "/repo-1"}, + {RepositoryID: "repository-2", TaskRepositoryID: "task-repository-2", RepositoryPath: "/repo-2"}, + }, + } + + prepared := buildEnvPrepareRequest(req, "/workspace", "local") + require.Len(t, prepared.Repositories, 2) + require.Equal(t, "task-repository-1", prepared.Repositories[0].TaskRepositoryID) + require.Equal(t, "task-repository-2", prepared.Repositories[1].TaskRepositoryID) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index 3d7faf110b..f463e51f1a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -750,6 +750,7 @@ func (ae *AgentExecution) EndSessionSpan() { // the top level. When LaunchRequest.Repositories is set, each entry produces // one prepared worktree under the shared TaskDirName. type RepoLaunchSpec struct { + TaskRepositoryID string RepositoryID string RepositoryPath string RepositoryURL string // Clone URL for remote executors that need to clone @@ -888,6 +889,7 @@ type LaunchRequest struct { UseWorktree bool // Whether to use a Git worktree for isolation WorktreeID string // Existing worktree ID to reuse (skip creation if set) RepositoryID string // Repository ID for worktree tracking + TaskRepositoryID string // Exact task_repositories row for worktree recovery RepositoryPath string // Path to the main repository (for worktree creation) BaseBranch string // Base branch for the worktree (e.g., "main") DefaultBranch string // Repository's default_branch, used as fallback when BaseBranch is missing @@ -933,6 +935,7 @@ func (r *LaunchRequest) RepoSpecs() []RepoLaunchSpec { return nil } return []RepoLaunchSpec{{ + TaskRepositoryID: r.TaskRepositoryID, RepositoryID: r.RepositoryID, RepositoryPath: r.RepositoryPath, RepoName: r.RepoName, diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index 0715e85767..fc93810929 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -159,32 +159,45 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch // Surface per-repo worktree results from the prepare step so the orchestrator // can persist N TaskEnvironmentRepo / TaskSessionWorktree rows when multi-repo. var worktrees []executor.RepoWorktreeResult + var requestedBaseBranch, baseBranch, baseBranchFallbackWarning string + if execution.PrepareResult != nil { + requestedBaseBranch = execution.PrepareResult.RequestedBaseBranch + baseBranch = execution.PrepareResult.BaseBranch + baseBranchFallbackWarning = execution.PrepareResult.BaseBranchFallbackWarning + } if execution.PrepareResult != nil && len(execution.PrepareResult.Worktrees) > 0 { worktrees = make([]executor.RepoWorktreeResult, 0, len(execution.PrepareResult.Worktrees)) for _, w := range execution.PrepareResult.Worktrees { worktrees = append(worktrees, executor.RepoWorktreeResult{ - RepositoryID: w.RepositoryID, - BranchSlug: w.BranchSlug, - WorktreeID: w.WorktreeID, - WorktreeBranch: w.WorktreeBranch, - WorktreePath: w.WorktreePath, - MainRepoGitDir: w.MainRepoGitDir, - ErrorMessage: w.ErrorMessage, + TaskRepositoryID: w.TaskRepositoryID, + RepositoryID: w.RepositoryID, + BranchSlug: w.BranchSlug, + WorktreeID: w.WorktreeID, + WorktreeBranch: w.WorktreeBranch, + WorktreePath: w.WorktreePath, + MainRepoGitDir: w.MainRepoGitDir, + RequestedBaseBranch: w.RequestedBaseBranch, + BaseBranch: w.BaseBranch, + BaseBranchFallbackWarning: w.BaseBranchFallbackWarning, + ErrorMessage: w.ErrorMessage, }) } } return &executor.LaunchAgentResponse{ - AgentExecutionID: execution.ID, - ContainerID: execution.ContainerID, - Status: execution.Status, - WorktreeID: worktreeID, - WorktreePath: worktreePath, - WorktreeBranch: worktreeBranch, - WorkspacePath: execution.WorkspacePath, - Metadata: metadata, - PrepareResult: execution.PrepareResult, - Worktrees: worktrees, + AgentExecutionID: execution.ID, + ContainerID: execution.ContainerID, + Status: execution.Status, + WorktreeID: worktreeID, + WorktreePath: worktreePath, + WorktreeBranch: worktreeBranch, + RequestedBaseBranch: requestedBaseBranch, + BaseBranch: baseBranch, + BaseBranchFallbackWarning: baseBranchFallbackWarning, + WorkspacePath: execution.WorkspacePath, + Metadata: metadata, + PrepareResult: execution.PrepareResult, + Worktrees: worktrees, }, nil } @@ -224,6 +237,7 @@ func buildLifecycleLaunchRequest( UseWorktree: req.UseWorktree, WorktreeID: req.WorktreeID, RepositoryID: req.RepositoryID, + TaskRepositoryID: req.TaskRepositoryID, RepositoryPath: req.RepositoryPath, BaseBranch: req.BaseBranch, DefaultBranch: req.DefaultBranch, @@ -280,6 +294,7 @@ func lifecycleRepoLaunchSpecs(repos []executor.RepoSpec) []lifecycle.RepoLaunchS specs := make([]lifecycle.RepoLaunchSpec, 0, len(repos)) for _, r := range repos { specs = append(specs, lifecycle.RepoLaunchSpec{ + TaskRepositoryID: r.TaskRepositoryID, RepositoryID: r.RepositoryID, RepositoryPath: r.RepositoryPath, RepositoryURL: r.RepositoryURL, diff --git a/apps/backend/internal/backendapp/gateway.go b/apps/backend/internal/backendapp/gateway.go index 37cc663bae..41250e34e7 100644 --- a/apps/backend/internal/backendapp/gateway.go +++ b/apps/backend/internal/backendapp/gateway.go @@ -309,6 +309,9 @@ func provideGateway( LoadSessionObservations: func(ctx context.Context, taskID string) (statussummary.SessionObservationSnapshot, error) { return loadTaskSessionObservations(ctx, taskRepo, activityProvider, taskID) }, + LoadTaskLaunchError: func(ctx context.Context, taskID string) (statussummary.TaskLaunchErrorObservation, error) { + return loadTaskLaunchErrorObservation(ctx, taskRepo, taskID) + }, LoadPendingActions: func(ctx context.Context, taskID string) (map[string]string, error) { return loadTaskPendingActions(ctx, taskRepo, taskID) }, @@ -482,10 +485,13 @@ func loadTaskSessionObservations( } if lastError, ok := models.LoadLastAgentError(session.Metadata); ok && !lastError.IsDismissed() { input.ActiveError = &statussummary.ActiveErrorSummary{ - SessionID: session.ID, - Stamp: lastError.Stamp(), - OccurredAt: lastError.OccurredAt, - Preview: lastError.Message, + SessionID: session.ID, + TaskRepositoryID: lastError.TaskRepositoryID, + Stamp: lastError.Stamp(), + OccurredAt: lastError.OccurredAt, + Preview: lastError.Message, + Category: lastError.Code, + RecoveryActions: lastError.RecoveryActions, } } snapshot.Sessions = append(snapshot.Sessions, input) @@ -493,6 +499,38 @@ func loadTaskSessionObservations( return snapshot, nil } +func loadTaskLaunchErrorObservation( + ctx context.Context, + taskRepo *sqliterepo.Repository, + taskID string, +) (statussummary.TaskLaunchErrorObservation, error) { + task, err := taskRepo.GetTask(ctx, taskID) + if err != nil { + return statussummary.TaskLaunchErrorObservation{}, err + } + if task == nil { + return statussummary.TaskLaunchErrorObservation{}, fmt.Errorf("task %q not found", taskID) + } + if _, present := task.Metadata[models.MetaKeyLastLaunchError]; !present { + return statussummary.TaskLaunchErrorObservation{Observed: true}, nil + } + errorValue, ok := models.LoadTaskLaunchError(task.Metadata) + if !ok { + return statussummary.TaskLaunchErrorObservation{}, nil + } + return statussummary.TaskLaunchErrorObservation{ + Observed: true, + Error: &statussummary.ActiveErrorSummary{ + TaskRepositoryID: errorValue.TaskRepositoryID, + Stamp: errorValue.Stamp(), + OccurredAt: errorValue.OccurredAt, + Preview: errorValue.Message, + Category: errorValue.Code, + RecoveryActions: errorValue.RecoveryActions, + }, + }, nil +} + func loadTaskPendingActions( ctx context.Context, taskRepo *sqliterepo.Repository, diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index e3968d5902..67c69bfdb6 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -140,6 +140,7 @@ func provideOrchestrator( // them the one task kind nothing else ever cleans up; the orchestrator needs // the manager to enforce the per-automation retention window. orchestratorSvc.SetWorktreeManager(lifecycleMgr.WorktreeManager()) + orchestratorSvc.SetTaskLaunchRecoveryService(taskSvc) msgCreator := &messageCreatorAdapter{svc: taskSvc, logger: log} orchestratorSvc.SetMessageCreator(msgCreator) @@ -239,6 +240,10 @@ func provideOrchestrator( // Wire repo cloner into executor for provider-backed repos with no local path orchestratorSvc.SetRepoCloner(repoCloner, &repoLocalPathUpdater{svc: taskSvc}) } + // Worktree fallback self-healing updates the exact task repository row + // after a successful launch. Keep this seam on the task service so the + // lifecycle and worktree packages remain task-agnostic. + orchestratorSvc.SetTaskRepositoryBaseBranchUpdater(&repoLocalPathUpdater{svc: taskSvc}) return orchestratorSvc, msgCreator, nil } @@ -944,6 +949,15 @@ func (u *repoLocalPathUpdater) UpdateRepositoryDefaultBranch(ctx context.Context return err } +func (u *repoLocalPathUpdater) UpdateTaskRepositoryBaseBranch(ctx context.Context, taskID, taskRepositoryID, baseBranch string) error { + _, err := u.svc.UpdateRepositoryBaseBranch(ctx, taskservice.UpdateRepositoryBaseBranchRequest{ + TaskID: taskID, + TaskRepositoryID: taskRepositoryID, + BaseBranch: baseBranch, + }) + return err +} + // repositoryResolverAdapter resolves GitHub repos by cloning + finding/creating DB records. type repositoryResolverAdapter struct { cloner *repoclone.Cloner diff --git a/apps/backend/internal/common/gitref/gitref.go b/apps/backend/internal/common/gitref/gitref.go index cd4cfb5d35..11b2ab0c0d 100644 --- a/apps/backend/internal/common/gitref/gitref.go +++ b/apps/backend/internal/common/gitref/gitref.go @@ -14,6 +14,14 @@ import ( const headFile = "HEAD" +type defaultBranchSource uint8 + +const ( + defaultBranchSourceRemote defaultBranchSource = iota + 1 + defaultBranchSourceNamedFallback + defaultBranchSourceCurrentHEAD +) + // DefaultBranch returns the repository's *integration* branch — the branch // that work is meant to merge back into. It is intentionally NOT the current // HEAD: a developer who runs the dialog while checked out on a feature @@ -32,26 +40,31 @@ const headFile = "HEAD" // feature branch still produce a value — callers that care about // correctness can override. func DefaultBranch(repoPath string) (string, error) { + branch, _, err := resolveDefaultBranch(repoPath) + return branch, err +} + +func resolveDefaultBranch(repoPath string) (string, defaultBranchSource, error) { safe, err := guardRepoPath(repoPath) if err != nil { - return "", err + return "", 0, err } gitDir, err := ResolveGitDir(safe) if err != nil { - return "", err + return "", 0, err } commonDir := ResolveCommonGitDir(gitDir) commonDir, err = guardRepoPath(commonDir) if err != nil { - return "", err + return "", 0, err } if branch := readOriginHEAD(commonDir); branch != "" { if branch == "master" { if refExists(commonDir, "refs/remotes/origin/main") { - return "main", nil + return "main", defaultBranchSourceRemote, nil } } - return branch, nil + return branch, defaultBranchSourceRemote, nil } for _, candidate := range []struct { ref string @@ -63,17 +76,18 @@ func DefaultBranch(repoPath string) (string, error) { {"refs/heads/master", "master"}, } { if refExists(commonDir, candidate.ref) { - return candidate.branch, nil + return candidate.branch, defaultBranchSourceNamedFallback, nil } } - return readHEADBranchFallback(gitDir) + branch, err := readHEADBranchFallback(gitDir) + return branch, defaultBranchSourceCurrentHEAD, err } // DefaultBranchOrEmpty returns DefaultBranch, but collapses detached HEAD's // literal "HEAD" sentinel to empty for callers that persist branch names. func DefaultBranchOrEmpty(repoPath string) (string, error) { - branch, err := DefaultBranch(repoPath) - if err != nil || branch == headFile { + branch, source, err := resolveDefaultBranch(repoPath) + if err != nil || source == defaultBranchSourceCurrentHEAD { return "", err } return branch, nil diff --git a/apps/backend/internal/common/gitref/gitref_test.go b/apps/backend/internal/common/gitref/gitref_test.go index 53869cd93f..558ec379f4 100644 --- a/apps/backend/internal/common/gitref/gitref_test.go +++ b/apps/backend/internal/common/gitref/gitref_test.go @@ -66,6 +66,33 @@ func TestDefaultBranch_AcceptsAbsolutePathToValidRepo(t *testing.T) { } } +func TestDefaultBranchOrEmpty_DoesNotUseFeatureBranchAsIntegrationDefault(t *testing.T) { + repoPath := t.TempDir() + gitDir := filepath.Join(repoPath, ".git") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatalf("mkdir git: %v", err) + } + if err := os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/feature/review\n"), 0o644); err != nil { + t.Fatalf("write HEAD: %v", err) + } + + branch, err := DefaultBranch(repoPath) + if err != nil { + t.Fatalf("DefaultBranch error: %v", err) + } + if branch != "feature/review" { + t.Fatalf("DefaultBranch = %q, want feature/review", branch) + } + + branch, err = DefaultBranchOrEmpty(repoPath) + if err != nil { + t.Fatalf("DefaultBranchOrEmpty error: %v", err) + } + if branch != "" { + t.Fatalf("DefaultBranchOrEmpty = %q, want empty", branch) + } +} + func TestDefaultBranch_PrefersMainWhenOriginHeadPointsAtMaster(t *testing.T) { repoPath := t.TempDir() gitDir := filepath.Join(repoPath, ".git") diff --git a/apps/backend/internal/common/netutil/addr_in_use_windows.go b/apps/backend/internal/common/netutil/addr_in_use_windows.go index 42248df5eb..cddec3bf34 100644 --- a/apps/backend/internal/common/netutil/addr_in_use_windows.go +++ b/apps/backend/internal/common/netutil/addr_in_use_windows.go @@ -10,5 +10,9 @@ import ( ) func isAddrInUse(err error) bool { - return errors.Is(err, syscall.EADDRINUSE) || errors.Is(err, windows.WSAEADDRINUSE) + // Windows can report WSAEACCES when a wildcard bind conflicts with a + // loopback listener, even though the requested port is already occupied. + return errors.Is(err, syscall.EADDRINUSE) || + errors.Is(err, windows.WSAEADDRINUSE) || + errors.Is(err, windows.WSAEACCES) } diff --git a/apps/backend/internal/orchestrator/event_handlers_automation.go b/apps/backend/internal/orchestrator/event_handlers_automation.go index 0620742272..37977f6c51 100644 --- a/apps/backend/internal/orchestrator/event_handlers_automation.go +++ b/apps/backend/internal/orchestrator/event_handlers_automation.go @@ -80,6 +80,7 @@ func (s *Service) SetWorktreeManager(mgr *worktree.Manager) { return } s.worktreeReaper = mgr + s.taskLaunchRecoveryWorktree = mgr } // subscribeAutomationEvents subscribes to automation-related events on the event bus. diff --git a/apps/backend/internal/orchestrator/event_handlers_github.go b/apps/backend/internal/orchestrator/event_handlers_github.go index c6ebaff9d3..e6ef29b1fb 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github.go +++ b/apps/backend/internal/orchestrator/event_handlers_github.go @@ -526,6 +526,9 @@ func (s *Service) shouldAutoStartStep(ctx context.Context, stepID string) bool { func (s *Service) autoStartReviewTask( ctx context.Context, evt *github.NewReviewPREvent, task *models.Task, ) { + if s.shouldSkipTerminalPRAutoStart(ctx, task) { + return + } // Compete for the one-shot auto-start token set at task creation. // The promotion that ran inside CreateTask may have already triggered // autoStartTaskForStep (Path A) asynchronously; only the first claimer diff --git a/apps/backend/internal/orchestrator/event_handlers_stall.go b/apps/backend/internal/orchestrator/event_handlers_stall.go index 2a6650f4e5..975b75f20e 100644 --- a/apps/backend/internal/orchestrator/event_handlers_stall.go +++ b/apps/backend/internal/orchestrator/event_handlers_stall.go @@ -23,8 +23,7 @@ const ( // errAgentNeverStarted is the launch-failure error recorded when a stalled // prompt's agent has emitted zero events since dispatch. Its Error() string // is persisted as the session's error_message by recordSessionLaunchFailure; -// it is not a missing-branch error, so handleSessionLaunchFailed's -// branch-guidance side effect stays inert for this path. +// it is not a missing-branch error, so no legacy branch guidance is created. var errAgentNeverStarted = errors.New("agent produced no output since start") // handleAgentStalled persists an advisory recovery affordance for a turn that diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index 34fadb90bf..425bc4b8d8 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -1158,6 +1158,11 @@ func (s *Service) transitionTaskSessionState( } if onChanged != nil { onChanged() + // The hook may persist state-specific metadata after the state CAS. Read + // the row again so the state event carries that metadata to projections. + // Without this refresh, the event publishes the pre-hook snapshot and a + // typed launch error can be durable but invisible in the task summary. + refreshed = s.refreshTaskSessionOr(ctx, sessionID, refreshed) } if isTerminalSessionState(nextState) { if err := s.expireTerminalClarificationWaiters(ctx, sessionID); err != nil { diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go index 36c10b9ca6..2b7b2be00c 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go @@ -449,6 +449,45 @@ func TestTransitionTaskSessionStateReportsAcceptedWrite(t *testing.T) { require.Equal(t, []bool{true}, canceller.expireContextDeadline) } +func TestTransitionTaskSessionStatePublishesMetadataWrittenByHook(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + eb := &recordingEventBus{} + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.eventBus = eb + errorValue := models.LastAgentError{ + Message: "The selected base branch is not available.", + Code: "base_branch_missing", + OccurredAt: time.Date(2026, 8, 19, 23, 10, 41, 0, time.UTC), + StampValue: "launch-error-stamp", + } + + changed, _, err := svc.transitionTaskSessionState( + ctx, + "t1", + "s1", + models.TaskSessionStateFailed, + errorValue.Message, + func() { + require.NoError(t, repo.SetSessionMetadataKey( + ctx, "s1", models.SessionMetaKeyLastAgentError, errorValue, + )) + }, + ) + + require.NoError(t, err) + require.True(t, changed) + require.Len(t, eb.events, 1) + data, ok := eb.events[0].event.Data.(map[string]interface{}) + require.True(t, ok) + metadata, ok := data["session_metadata"].(map[string]interface{}) + require.True(t, ok) + lastError, ok := metadata[models.SessionMetaKeyLastAgentError].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, "base_branch_missing", lastError["code"]) +} + func TestTransitionTaskSessionStateReportsPersistenceFailure(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index d08f245d16..882f9ca8d6 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -1045,6 +1045,9 @@ func (s *Service) autoStartTaskForStep(ctx context.Context, taskID, stepID, even if s.dependencyBlocksAutoStart(ctx, taskID, eventName) { return } + if task != nil && s.shouldSkipTerminalPRAutoStart(ctx, task) { + return + } if s.launchDeferredTask(ctx, task, eventName) { return } @@ -1065,6 +1068,9 @@ func (s *Service) autoStartTaskForLoadedStep(ctx context.Context, task *models.T if task == nil || task.QueuedForStepID != "" || step == nil { return } + if s.shouldSkipTerminalPRAutoStart(ctx, task) { + return + } if s.launchDeferredTask(ctx, task, eventName) { return } diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index c609476c53..4e83c82e0d 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -395,6 +395,7 @@ type LaunchAgentRequest struct { UseWorktree bool // Whether to use a Git worktree for isolation WorktreeID string // Existing worktree ID to reuse (skip creation if set) RepositoryID string // Repository ID for worktree tracking + TaskRepositoryID string // Exact task_repositories row for worktree recovery RepositoryPath string // Path to the main repository (for worktree creation) BaseBranch string // Base branch for the worktree (e.g., "main") DefaultBranch string // Repository's default_branch, used as a fallback when BaseBranch is missing @@ -437,6 +438,7 @@ type WorkspaceFolderSpec struct{ Name, LocalPath string } // the orchestrator package does not need to import lifecycle types into its // public API. type RepoSpec struct { + TaskRepositoryID string RepositoryID string RepositoryPath string RepositoryURL string @@ -530,15 +532,18 @@ type LaunchContext struct { // LaunchAgentResponse contains the result of launching an agent type LaunchAgentResponse struct { - AgentExecutionID string - ContainerID string - Status v1.AgentStatus - WorktreeID string - WorktreePath string - WorktreeBranch string - WorkspacePath string // Effective workspace path (may differ from WorktreePath for quick-chat sessions) - Metadata map[string]interface{} - PrepareResult *lifecycle.EnvPrepareResult `json:"-"` // Carried from lifecycle.Launch for synchronous persistence + AgentExecutionID string + ContainerID string + Status v1.AgentStatus + WorktreeID string + WorktreePath string + WorktreeBranch string + RequestedBaseBranch string + BaseBranch string + BaseBranchFallbackWarning string + WorkspacePath string // Effective workspace path (may differ from WorktreePath for quick-chat sessions) + Metadata map[string]interface{} + PrepareResult *lifecycle.EnvPrepareResult `json:"-"` // Carried from lifecycle.Launch for synchronous persistence // Worktrees is the per-repository preparer result list when the launch is // multi-repo. Empty for single-repo launches; the legacy WorktreeID/Path/ @@ -549,13 +554,17 @@ type LaunchAgentResponse struct { // RepoWorktreeResult mirrors lifecycle.RepoWorktreeResult for the orchestrator // API surface. One entry per repository prepared during a multi-repo launch. type RepoWorktreeResult struct { - RepositoryID string - BranchSlug string - WorktreeID string - WorktreeBranch string - WorktreePath string - MainRepoGitDir string - ErrorMessage string + TaskRepositoryID string + RepositoryID string + BranchSlug string + WorktreeID string + WorktreeBranch string + WorktreePath string + MainRepoGitDir string + RequestedBaseBranch string + BaseBranch string + BaseBranchFallbackWarning string + ErrorMessage string } // TaskExecution tracks an active task execution @@ -665,6 +674,11 @@ type AgentStartFailedFunc func(ctx context.Context, taskID, sessionID, agentExec // creating repository-scoped user-facing status messages tied to launch errors. type LaunchFailedFunc func(ctx context.Context, taskID, sessionID, repositoryID string, err error) +// LaunchFailureReviewEligibilityFunc reports whether a failed launch can offer +// the mark-review-done recovery action. The resolver owns workflow and PR +// lookups; an error omits the action without blocking failure persistence. +type LaunchFailureReviewEligibilityFunc func(ctx context.Context, taskID string) (bool, error) + // PrimarySessionSetFunc is called when the first session for a task is marked // primary. This lets the orchestrator publish a task.updated event so the // frontend receives the primary_session_id. @@ -759,6 +773,8 @@ type Executor struct { // Callback for session launch failures (pre-start). Allows orchestrator // to emit user-friendly guidance for known failure patterns. onLaunchFailed LaunchFailedFunc + // Optional resolver for the mark-review-done recovery action. + launchFailureReviewEligibility LaunchFailureReviewEligibilityFunc // Callback when the first session for a task is marked primary. onPrimarySessionSet PrimarySessionSetFunc @@ -785,8 +801,9 @@ type Executor struct { officeSessionLocks sync.Map // map[string]*sync.Mutex // Optional cloner for provider-backed repos without a local path. - repoCloner RepoCloner - repoUpdater RepoUpdater + repoCloner RepoCloner + repoUpdater RepoUpdater + taskRepositoryBaseBranchUpdater TaskRepositoryBaseBranchUpdater } // taskEnvLock returns the per-task mutex for env persistence, creating one on @@ -881,6 +898,13 @@ type RepoUpdater interface { UpdateRepositoryDefaultBranch(ctx context.Context, repositoryID, defaultBranch string) error } +// TaskRepositoryBaseBranchUpdater persists a recovered base branch on the +// exact task_repositories row. It is optional so lifecycle and executor unit +// tests remain independent of the task service. +type TaskRepositoryBaseBranchUpdater interface { + UpdateTaskRepositoryBaseBranch(ctx context.Context, taskID, taskRepositoryID, baseBranch string) error +} + // ExecutorConfig holds configuration for the Executor type ExecutorConfig struct { ShellPrefs ShellPreferenceProvider @@ -969,6 +993,12 @@ func (e *Executor) SetRepoCloner(cloner RepoCloner, updater RepoUpdater) { e.repoUpdater = updater } +// SetTaskRepositoryBaseBranchUpdater wires the task-service seam used by +// successful worktree fallback self-healing. +func (e *Executor) SetTaskRepositoryBaseBranchUpdater(updater TaskRepositoryBaseBranchUpdater) { + e.taskRepositoryBaseBranchUpdater = updater +} + // SetOnAgentStartFailed sets a callback for agent process start failures. // This allows the orchestrator to intercept auth errors and treat them as // recoverable instead of terminal failures. @@ -994,6 +1024,14 @@ func (e *Executor) SetOnLaunchFailed(fn LaunchFailedFunc) { e.onLaunchFailed = fn } +// SetLaunchFailureReviewEligibility wires the narrow review-completion +// eligibility check used when a launch fails before an agent starts. +func (e *Executor) SetLaunchFailureReviewEligibility( + fn LaunchFailureReviewEligibilityFunc, +) { + e.launchFailureReviewEligibility = fn +} + // SetCapabilities sets the executor type capabilities provider. func (e *Executor) SetCapabilities(c ExecutorTypeCapabilities) { e.capabilities = c diff --git a/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go b/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go index 84e5762e5c..ed95e01c74 100644 --- a/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go +++ b/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go @@ -197,6 +197,7 @@ func topLevelLaunchRepoSpec(req *LaunchAgentRequest) (RepoSpec, bool) { return RepoSpec{}, false } return RepoSpec{ + TaskRepositoryID: req.TaskRepositoryID, RepositoryID: req.RepositoryID, RepositoryPath: req.RepositoryPath, RepositoryURL: req.RepositoryURL, diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 63107cc98c..0ebf7b39a4 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -1114,7 +1114,8 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses // Call the AgentManager to launch the container resp, err := e.agentManager.LaunchAgent(ctx, req) if err != nil { - return nil, e.handleLaunchFailure(ctx, task.ID, sessionID, failingLaunchRepositoryID(req, err), err) + repositoryID, taskRepositoryID := failingLaunchRepositoryIdentity(req, err) + return nil, e.handleLaunchFailure(ctx, task.ID, sessionID, repositoryID, taskRepositoryID, err) } // Create or update the task environment with launch results @@ -1139,28 +1140,45 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses // checkout branch in the request. Ambiguous or unrecognized branches fail // closed: no repository-scoped destructive guidance can be offered. func failingLaunchRepositoryID(req *LaunchAgentRequest, launchErr error) string { + repositoryID, _ := failingLaunchRepositoryIdentity(req, launchErr) + return repositoryID +} + +func failingLaunchRepositoryIdentity( + req *LaunchAgentRequest, + launchErr error, +) (repositoryID, taskRepositoryID string) { if req == nil { - return "" + return "", "" } if len(req.Repositories) == 0 { - return req.RepositoryID + return req.RepositoryID, req.TaskRepositoryID } branch := extractLaunchFailureBranch(launchErr) - if branch == "" { - return "" + if len(req.Repositories) == 1 && branch == "" { + return req.Repositories[0].RepositoryID, req.Repositories[0].TaskRepositoryID } - var repositoryID string + if branch == "" { + return "", "" + } + var matched RepoSpec + found := false for _, spec := range req.Repositories { - if strings.TrimSpace(spec.CheckoutBranch) != branch { + if strings.TrimSpace(spec.CheckoutBranch) != branch && + strings.TrimSpace(spec.BaseBranch) != branch { continue } - if repositoryID != "" { - return "" + if found { + return "", "" } - repositoryID = spec.RepositoryID + matched = spec + found = true } - return repositoryID + if !found { + return "", "" + } + return matched.RepositoryID, matched.TaskRepositoryID } var ( @@ -1196,12 +1214,16 @@ func resumeTokenForExecutionProfile(running *models.ExecutorRunning, profileID s // handleLaunchFailure marks the session and task as FAILED and returns a // sanitized error that preserves the original cause for errors.Is/errors.As. -func (e *Executor) handleLaunchFailure(ctx context.Context, taskID, sessionID, repositoryID string, launchErr error) error { +func (e *Executor) handleLaunchFailure( + ctx context.Context, + taskID, sessionID, repositoryID, taskRepositoryID string, + launchErr error, +) error { // Detach from caller context so failure bookkeeping completes even if the // original request context was cancelled. failCtx := context.WithoutCancel(ctx) safeErr, changed := e.transitionLaunchFailure( - failCtx, taskID, sessionID, repositoryID, launchErr, + failCtx, taskID, sessionID, repositoryID, taskRepositoryID, launchErr, ) if changed { if updateErr := e.updateTaskState(failCtx, taskID, v1.TaskStateFailed); updateErr != nil { @@ -1220,7 +1242,7 @@ func (e *Executor) handleEarlyLaunchFailure( ) error { failCtx := context.WithoutCancel(ctx) safeErr, changed := e.transitionLaunchFailure( - failCtx, taskID, sessionID, repositoryID, launchErr, + failCtx, taskID, sessionID, repositoryID, "", launchErr, ) if !changed { return safeErr @@ -1268,7 +1290,7 @@ func (e *Executor) handleEarlyLaunchFailure( func (e *Executor) transitionLaunchFailure( failCtx context.Context, - taskID, sessionID, repositoryID string, + taskID, sessionID, repositoryID, taskRepositoryID string, launchErr error, ) (error, bool) { safeErr := routingerr.SanitizeError(launchErr) @@ -1276,8 +1298,13 @@ func (e *Executor) transitionLaunchFailure( zap.String("task_id", taskID), zap.Error(safeErr)) var onChanged func() - if e.onLaunchFailed != nil { - onChanged = func() { + onChanged = func() { + e.persistLastAgentError( + failCtx, + sessionID, + e.buildLastAgentError(failCtx, taskID, taskRepositoryID, launchErr), + ) + if e.onLaunchFailed != nil { e.onLaunchFailed(failCtx, taskID, sessionID, repositoryID, safeErr) } } @@ -1479,6 +1506,7 @@ func buildRepoSpecs(allRepos []*repoInfo) []RepoSpec { out := make([]RepoSpec, 0, len(allRepos)) for _, info := range allRepos { spec := RepoSpec{ + TaskRepositoryID: info.TaskRepositoryID, RepositoryID: info.RepositoryID, RepositoryPath: info.RepositoryPath, BaseBranch: info.BaseBranch, @@ -1548,6 +1576,7 @@ func (e *Executor) applyRepositoryConfig(req *LaunchAgentRequest, task *v1.Task, if repoInfo.RepositoryPath != "" { req.UseWorktree = shouldUseWorktree(execConfig.ExecutorType) req.RepositoryID = repoInfo.RepositoryID + req.TaskRepositoryID = repoInfo.TaskRepositoryID req.RepositoryPath = repoInfo.RepositoryPath req.BaseBranch = repoInfo.BaseBranch req.CheckoutBranch = repoInfo.CheckoutBranch @@ -1943,6 +1972,7 @@ func (e *Executor) persistTaskEnvironment( // environment-repository rows are the only physical-worktree record, // so single-repo launches write one row here too. e.persistTaskEnvironmentRepos(ctx, existingEnv.ID, environmentReposForLaunch(req, resp)) + e.selfHealTaskRepositoryBaseBranches(ctx, taskID, req, resp) return } @@ -1970,6 +2000,41 @@ func (e *Executor) persistTaskEnvironment( return } session.TaskEnvironmentID = env.ID + e.selfHealTaskRepositoryBaseBranches(ctx, taskID, req, resp) +} + +func (e *Executor) selfHealTaskRepositoryBaseBranches( + ctx context.Context, + taskID string, + req *LaunchAgentRequest, + resp *LaunchAgentResponse, +) { + if e.taskRepositoryBaseBranchUpdater == nil || req == nil || resp == nil { + return + } + if len(resp.Worktrees) > 0 { + for _, result := range resp.Worktrees { + e.persistRecoveredBaseBranch(ctx, taskID, result.TaskRepositoryID, result.RequestedBaseBranch, result.BaseBranch, result.BaseBranchFallbackWarning) + } + return + } + e.persistRecoveredBaseBranch(ctx, taskID, req.TaskRepositoryID, resp.RequestedBaseBranch, resp.BaseBranch, resp.BaseBranchFallbackWarning) +} + +func (e *Executor) persistRecoveredBaseBranch( + ctx context.Context, + taskID, taskRepositoryID, requestedBranch, resolvedBranch, warning string, +) { + if strings.TrimSpace(warning) == "" || taskRepositoryID == "" || resolvedBranch == "" || resolvedBranch == requestedBranch { + return + } + if err := e.taskRepositoryBaseBranchUpdater.UpdateTaskRepositoryBaseBranch(ctx, taskID, taskRepositoryID, resolvedBranch); err != nil { + e.logger.Warn("failed to persist recovered task repository base branch", + zap.String("task_id", taskID), + zap.String("task_repository_id", taskRepositoryID), + zap.String("base_branch", resolvedBranch), + zap.Error(err)) + } } // environmentReposForLaunch returns the environment-repository rows for a diff --git a/apps/backend/internal/orchestrator/executor/executor_launch_failure_classification_test.go b/apps/backend/internal/orchestrator/executor/executor_launch_failure_classification_test.go new file mode 100644 index 0000000000..d2d0c41fa1 --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/executor_launch_failure_classification_test.go @@ -0,0 +1,76 @@ +package executor + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/worktree" +) + +func TestClassifyLaunchFailureUsesTypedBaseBranchCategory(t *testing.T) { + classification := classifyLaunchFailure(errors.Join( + errors.New("environment preparation failed"), worktree.ErrInvalidBaseBranch, + )) + if classification.code != models.LaunchErrorCategoryBaseBranchMissing { + t.Fatalf("classification code = %q, want %q", classification.code, models.LaunchErrorCategoryBaseBranchMissing) + } + if classification.message == "" || classification.message == "environment preparation failed" { + t.Fatalf("classification message = %q, want safe user message", classification.message) + } +} + +func TestTransitionLaunchFailurePersistsTypedErrorAndExactTaskRepository(t *testing.T) { + repo := newMockRepository() + repo.sessions["session-1"] = &models.TaskSession{ + ID: "session-1", TaskID: "task-1", State: models.TaskSessionStateCreated, + } + exec := newTestExecutor(t, &mockAgentManager{}, repo) + + _, changed := exec.transitionLaunchFailure( + context.Background(), "task-1", "session-1", "repo-1", "task-repo-1", + errors.Join(errors.New("prepare failed"), worktree.ErrInvalidBaseBranch), + ) + if !changed { + t.Fatal("transitionLaunchFailure did not transition the session") + } + session, err := repo.GetTaskSession(context.Background(), "session-1") + if err != nil { + t.Fatalf("GetTaskSession: %v", err) + } + errorValue, found := models.LoadLastAgentError(session.Metadata) + if !found { + t.Fatal("typed launch error was not persisted") + } + if errorValue.Code != models.LaunchErrorCategoryBaseBranchMissing { + t.Fatalf("error code = %q, want %q", errorValue.Code, models.LaunchErrorCategoryBaseBranchMissing) + } + if errorValue.TaskRepositoryID != "task-repo-1" { + t.Fatalf("task repository id = %q, want task-repo-1", errorValue.TaskRepositoryID) + } + if len(errorValue.RecoveryActions) != 2 || + errorValue.RecoveryActions[0] != models.RecoveryActionRetryDefault || + errorValue.RecoveryActions[1] != models.RecoveryActionPickBaseBranch { + t.Fatalf("recovery actions = %#v, want default retry and branch picker", errorValue.RecoveryActions) + } +} + +func TestLaunchFailureReviewActionRequiresSuccessfulEligibilityResolver(t *testing.T) { + exec := &Executor{} + exec.launchFailureReviewEligibility = func(context.Context, string) (bool, error) { + return true, nil + } + errorValue := exec.buildLastAgentError(context.Background(), "task-1", "", errors.New("start failed")) + if len(errorValue.RecoveryActions) != 1 || errorValue.RecoveryActions[0] != models.RecoveryActionMarkReviewDone { + t.Fatalf("eligible recovery actions = %#v, want mark_review_done", errorValue.RecoveryActions) + } + + exec.launchFailureReviewEligibility = func(context.Context, string) (bool, error) { + return false, errors.New("lookup failed") + } + errorValue = exec.buildLastAgentError(context.Background(), "task-1", "", errors.New("start failed")) + if len(errorValue.RecoveryActions) != 0 { + t.Fatalf("failed eligibility lookup exposed recovery actions = %#v", errorValue.RecoveryActions) + } +} diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index 7ca7b6ecd8..e920582161 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -61,6 +61,7 @@ func isTerminalSessionState(state models.TaskSessionState) bool { // repoInfo holds resolved repository details for agent launch. type repoInfo struct { + TaskRepositoryID string RepositoryID string RepositoryPath string BaseBranch string @@ -159,11 +160,12 @@ func (e *Executor) resolveTaskRepoInfoForSession( ctx context.Context, sessionID string, tr *models.TaskRepository, ) (*repoInfo, error) { info := &repoInfo{ - RepositoryID: tr.RepositoryID, - BaseBranch: tr.BaseBranch, - CheckoutBranch: tr.CheckoutBranch, - PRNumber: prNumberFromMetadata(tr.Metadata), - Position: tr.Position, + TaskRepositoryID: tr.ID, + RepositoryID: tr.RepositoryID, + BaseBranch: tr.BaseBranch, + CheckoutBranch: tr.CheckoutBranch, + PRNumber: prNumberFromMetadata(tr.Metadata), + Position: tr.Position, } if binding, found, err := models.LoadRemoteContribution(tr.Metadata); err != nil { return nil, fmt.Errorf("load remote contribution for task repository %q: %w", tr.ID, err) @@ -1138,6 +1140,7 @@ func (e *Executor) applyResumeRepoConfig( return "", err } repositoryID, baseBranch := resolveResumeRepoIDAndBranch(task, session) + baseBranch = resolveResumeBaseBranch(repositoryID, baseBranch, allRepos) if baseBranch != "" { req.Branch = baseBranch } @@ -1216,6 +1219,29 @@ func resolveResumeRepoIDAndBranch(task *v1.Task, session *models.TaskSession) (s return repositoryID, baseBranch } +// resolveResumeBaseBranch prefers the current task-repository row when a +// recovery action changed it after the session was created. A session keeps a +// legacy primary base snapshot, but that snapshot must not overwrite an exact +// task-repository recovery choice on the next launch. If the task has multiple +// rows for the same repository, the legacy session value remains the only +// unambiguous choice because TaskSession predates exact row identity. +func resolveResumeBaseBranch(repositoryID, sessionBaseBranch string, repos []*repoInfo) string { + var match *repoInfo + for _, info := range repos { + if info == nil || info.RepositoryID != repositoryID { + continue + } + if match != nil { + return sessionBaseBranch + } + match = info + } + if match != nil && strings.TrimSpace(match.BaseBranch) != "" { + return match.BaseBranch + } + return sessionBaseBranch +} + // applyResumeRepoBasics copies the repository's local path and setup script // onto the request. Pulled out of applyResumeRepoConfig so the parent's // cyclomatic complexity stays inside the lint budget. @@ -1296,6 +1322,9 @@ func (e *Executor) applyResumeWorktreeConfig( } // Carry forward CheckoutBranch from task repository (e.g. PR head branch) primaryTaskRepo, _ := e.repo.GetPrimaryTaskRepository(ctx, task.ID) + if primaryTaskRepo != nil && primaryTaskRepo.RepositoryID == repositoryID { + req.TaskRepositoryID = primaryTaskRepo.ID + } if primaryTaskRepo != nil && primaryTaskRepo.CheckoutBranch != "" { req.CheckoutBranch = primaryTaskRepo.CheckoutBranch req.PRNumber = prNumberFromMetadata(primaryTaskRepo.Metadata) diff --git a/apps/backend/internal/orchestrator/executor/executor_resume_test.go b/apps/backend/internal/orchestrator/executor/executor_resume_test.go index 0a43795645..fd7d37cb31 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume_test.go @@ -1152,6 +1152,25 @@ func TestApplyResumeRepoConfig_BaseBranchByExecutorType(t *testing.T) { } } +func TestResolveResumeBaseBranchPrefersCurrentTaskRepository(t *testing.T) { + got := resolveResumeBaseBranch("repo-1", "branch-that-no-longer-exists", []*repoInfo{ + {RepositoryID: "repo-1", BaseBranch: "main"}, + }) + if got != "main" { + t.Fatalf("base branch = %q, want main", got) + } +} + +func TestResolveResumeBaseBranchKeepsLegacyValueWhenRepositoryRowsAreAmbiguous(t *testing.T) { + got := resolveResumeBaseBranch("repo-1", "session-base", []*repoInfo{ + {RepositoryID: "repo-1", BaseBranch: "main"}, + {RepositoryID: "repo-1", BaseBranch: "release"}, + }) + if got != "session-base" { + t.Fatalf("base branch = %q, want session-base", got) + } +} + func TestApplyResumeWorktreeConfigPreservesSelectedRepositoryDestination(t *testing.T) { primaryDestination := resumeTestContributionDestination("200") selectedDestination := resumeTestContributionDestination("201") diff --git a/apps/backend/internal/orchestrator/executor/launch_failure.go b/apps/backend/internal/orchestrator/executor/launch_failure.go new file mode 100644 index 0000000000..ed331396d0 --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/launch_failure.go @@ -0,0 +1,96 @@ +package executor + +import ( + "context" + "errors" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/worktree" +) + +type launchFailureClassification struct { + code string + message string +} + +func classifyLaunchFailure(err error) launchFailureClassification { + switch { + case errors.Is(err, worktree.ErrInvalidBaseBranch): + return launchFailureClassification{ + code: models.LaunchErrorCategoryBaseBranchMissing, + message: "The selected base branch is not available.", + } + case errors.Is(err, worktree.ErrRemoteDefaultUnresolved): + return launchFailureClassification{ + code: models.LaunchErrorCategoryDefaultBranchUnresolved, + message: "The repository default branch could not be resolved.", + } + default: + return launchFailureClassification{ + code: models.LaunchErrorCategoryGenericLaunchFailure, + message: "The agent could not start.", + } + } +} + +func launchFailureRecoveryActions(taskRepositoryID string, markReviewDone bool) []string { + actions := make([]string, 0, 3) + if strings.TrimSpace(taskRepositoryID) != "" { + actions = append(actions, + models.RecoveryActionRetryDefault, + models.RecoveryActionPickBaseBranch, + ) + } + if markReviewDone { + actions = append(actions, models.RecoveryActionMarkReviewDone) + } + return models.NormalizeRecoveryActions(actions) +} + +func (e *Executor) buildLastAgentError( + ctx context.Context, + taskID, taskRepositoryID string, + launchErr error, +) models.LastAgentError { + classification := classifyLaunchFailure(launchErr) + markReviewDone := false + if e.launchFailureReviewEligibility != nil { + eligible, err := e.launchFailureReviewEligibility(ctx, taskID) + if err != nil { + if e.logger != nil { + e.logger.Debug("launch failure review eligibility lookup failed", + zap.String("task_id", taskID), zap.Error(err)) + } + } else { + markReviewDone = eligible + } + } + occurredAt := time.Now().UTC() + return models.LastAgentError{ + Message: classification.message, + OccurredAt: occurredAt, + Code: classification.code, + RecoveryActions: launchFailureRecoveryActions(taskRepositoryID, markReviewDone), + TaskRepositoryID: taskRepositoryID, + StampValue: models.StableLaunchErrorStamp( + taskID, classification.code, taskRepositoryID, occurredAt.Format(time.RFC3339Nano), + ), + } +} + +func (e *Executor) persistLastAgentError( + ctx context.Context, + sessionID string, + errorValue models.LastAgentError, +) { + if err := e.repo.SetSessionMetadataKey( + ctx, sessionID, models.SessionMetaKeyLastAgentError, errorValue, + ); err != nil { + e.logger.Warn("failed to persist typed launch error", + zap.String("session_id", sessionID), zap.Error(err)) + } +} diff --git a/apps/backend/internal/orchestrator/executor/task_repository_identity_test.go b/apps/backend/internal/orchestrator/executor/task_repository_identity_test.go new file mode 100644 index 0000000000..3e25a2cd5c --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/task_repository_identity_test.go @@ -0,0 +1,29 @@ +package executor + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildRepoSpecsCarriesExactTaskRepositoryID(t *testing.T) { + specs := buildRepoSpecs([]*repoInfo{ + {RepositoryID: "repository-1", TaskRepositoryID: "task-repository-1", RepositoryPath: "/repo"}, + {RepositoryID: "repository-1", TaskRepositoryID: "task-repository-2", RepositoryPath: "/repo"}, + }) + + require.Len(t, specs, 2) + require.Equal(t, "task-repository-1", specs[0].TaskRepositoryID) + require.Equal(t, "task-repository-2", specs[1].TaskRepositoryID) +} + +func TestTopLevelLaunchRepoSpecCarriesExactTaskRepositoryID(t *testing.T) { + spec, ok := topLevelLaunchRepoSpec(&LaunchAgentRequest{ + RepositoryID: "repository-1", + TaskRepositoryID: "task-repository-1", + RepositoryPath: "/repo", + }) + + require.True(t, ok) + require.Equal(t, "task-repository-1", spec.TaskRepositoryID) +} diff --git a/apps/backend/internal/orchestrator/executor/task_repository_self_heal_test.go b/apps/backend/internal/orchestrator/executor/task_repository_self_heal_test.go new file mode 100644 index 0000000000..bd37add6e5 --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/task_repository_self_heal_test.go @@ -0,0 +1,68 @@ +package executor + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/stretchr/testify/require" +) + +type recordingTaskRepositoryBaseBranchUpdater struct { + calls []taskRepositoryBaseBranchUpdate + err error +} + +type taskRepositoryBaseBranchUpdate struct { + taskID, taskRepositoryID, baseBranch string +} + +func (u *recordingTaskRepositoryBaseBranchUpdater) UpdateTaskRepositoryBaseBranch( + _ context.Context, taskID, taskRepositoryID, baseBranch string, +) error { + u.calls = append(u.calls, taskRepositoryBaseBranchUpdate{taskID, taskRepositoryID, baseBranch}) + return u.err +} + +func TestSelfHealTaskRepositoryBaseBranchesWritesOnlyChangedFallbacks(t *testing.T) { + updater := &recordingTaskRepositoryBaseBranchUpdater{} + exec := &Executor{logger: logger.Default(), taskRepositoryBaseBranchUpdater: updater} + + exec.selfHealTaskRepositoryBaseBranches(context.Background(), "task-1", &LaunchAgentRequest{ + TaskRepositoryID: "task-repo-single", + }, &LaunchAgentResponse{ + RequestedBaseBranch: "stale", + BaseBranch: "main", + BaseBranchFallbackWarning: "used live default", + }) + + exec.selfHealTaskRepositoryBaseBranches(context.Background(), "task-1", &LaunchAgentRequest{}, &LaunchAgentResponse{ + Worktrees: []RepoWorktreeResult{ + {TaskRepositoryID: "task-repo-a", RequestedBaseBranch: "stale", BaseBranch: "main", BaseBranchFallbackWarning: "fallback"}, + {TaskRepositoryID: "task-repo-b", RequestedBaseBranch: "main", BaseBranch: "main", BaseBranchFallbackWarning: "same value"}, + {TaskRepositoryID: "", RequestedBaseBranch: "stale", BaseBranch: "main", BaseBranchFallbackWarning: "ambiguous"}, + }, + }) + + require.Equal(t, []taskRepositoryBaseBranchUpdate{ + {taskID: "task-1", taskRepositoryID: "task-repo-single", baseBranch: "main"}, + {taskID: "task-1", taskRepositoryID: "task-repo-a", baseBranch: "main"}, + }, updater.calls) +} + +func TestSelfHealTaskRepositoryBaseBranchesDoesNotFailLaunchOnWriteError(t *testing.T) { + updater := &recordingTaskRepositoryBaseBranchUpdater{err: errors.New("storage unavailable")} + exec := &Executor{logger: logger.Default(), taskRepositoryBaseBranchUpdater: updater} + + exec.selfHealTaskRepositoryBaseBranches(context.Background(), "task-1", &LaunchAgentRequest{}, &LaunchAgentResponse{ + Worktrees: []RepoWorktreeResult{{ + TaskRepositoryID: "task-repo-1", + RequestedBaseBranch: "stale", + BaseBranch: "main", + BaseBranchFallbackWarning: "fallback", + }}, + }) + + require.Len(t, updater.calls, 1) +} diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index b80c3f8f62..8d0a8bd2a9 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -38,6 +38,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionSessionLaunch, h.wsLaunchSession) d.RegisterFunc(ws.ActionSessionEnsure, h.wsEnsureSession) d.RegisterFunc(ws.ActionSessionRecover, h.wsRecoverSession) + d.RegisterFunc(ws.ActionTaskLaunchRecover, h.wsRecoverTaskLaunch) d.RegisterFunc(ws.ActionSessionResetContext, h.wsResetContext) d.RegisterFunc(ws.ActionSessionStop, h.wsStopSession) d.RegisterFunc(ws.ActionSessionDelete, h.wsDeleteSession) @@ -238,6 +239,42 @@ func (h *Handlers) wsRecoverSession(ctx context.Context, msg *ws.Message) (*ws.M return ws.NewResponse(msg.ID, msg.Action, resp) } +// wsRecoverTaskLaunch applies one task-scoped launch recovery action. The +// service authorizes task_id before it reads the task, session, repository, or +// workflow rows named by the request. +func (h *Handlers) wsRecoverTaskLaunch(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req orchestrator.TaskLaunchRecoveryRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.TaskID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + if req.Action == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "action is required", nil) + } + if req.ErrorStamp == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "error_stamp is required", nil) + } + if req.Action != "mark_review_done" && req.TaskRepositoryID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_repository_id is required for branch recovery", nil) + } + if req.Action == "pick_base_branch" && req.BaseBranch == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "base_branch is required for branch selection", nil) + } + + response, err := h.service.RecoverTaskLaunch(ctx, &req) + if err != nil { + h.logger.Error("failed to recover task launch", + zap.String("task_id", req.TaskID), + zap.String("session_id", req.SessionID), + zap.String("action", req.Action), + zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, response) +} + type wsStopTaskRequest struct { TaskID string `json:"task_id"` Reason string `json:"reason,omitempty"` diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 566e4c3b47..01897a58af 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -16,20 +16,16 @@ import ( "errors" "fmt" "io" - "regexp" - "strconv" "strings" "sync" "time" "go.uber.org/zap" - "github.com/kandev/kandev/internal/agent/runtime/routingerr" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" - "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/messagequeue" "github.com/kandev/kandev/internal/orchestrator/queue" @@ -40,7 +36,6 @@ import ( taskservice "github.com/kandev/kandev/internal/task/service" "github.com/kandev/kandev/internal/workflow/engine" wfmodels "github.com/kandev/kandev/internal/workflow/models" - "github.com/kandev/kandev/internal/worktree" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -698,6 +693,16 @@ type Service struct { // manager simply keeps every run's checkout. Set via SetWorktreeManager. worktreeReaper automationWorktreeReaper + // taskLaunchRecoveryWorktree resolves live remote defaults for the explicit + // task.launch.recover action. It is nil in isolated orchestrator tests. + taskLaunchRecoveryWorktree taskLaunchRecoveryWorktree + // taskLaunchRecoveryTasks is the task-service seam used by the same action + // for exact base-branch writes, remote-branch validation, and terminal moves. + taskLaunchRecoveryTasks taskLaunchRecoveryTaskService + // taskLaunchRecoveryRepo carries the repository reads and repository-default + // writes that are outside sessionExecutorStore's common contract. + taskLaunchRecoveryRepo taskLaunchRecoveryRepository + // idleReaper is the periodic background loop that calls // reclaimIdleSession on rows older than idleReaperMinIdle. Nil-safe: // callers that don't need the reaper (most tests, ephemeral @@ -1001,6 +1006,10 @@ func NewService( }) } } + var taskLaunchRecoveryRepo taskLaunchRecoveryRepository + if recoveryRepo, ok := repo.(taskLaunchRecoveryRepository); ok { + taskLaunchRecoveryRepo = recoveryRepo + } // Create the service (watcher will be created after we have handlers) sendNowCtx, sendNowCancel := context.WithCancel(context.Background()) @@ -1015,6 +1024,7 @@ func NewService( executor: exec, scheduler: sched, messageQueue: msgQueue, + taskLaunchRecoveryRepo: taskLaunchRecoveryRepo, clarificationWatchdogTimeout: 15 * time.Second, gitSnapshotCache: newGitSnapshotCache(), reservedPromptCallbacks: newReservedPromptCallbackOwner(), @@ -1081,7 +1091,7 @@ func NewService( exec.SetOnTaskReviewStateReconcile(func(ctx context.Context, taskID, completedSessionID string) { s.writeTaskReviewState(ctx, taskID, completedSessionID) }) - exec.SetOnLaunchFailed(s.handleSessionLaunchFailed) + exec.SetLaunchFailureReviewEligibility(s.resolveLaunchFailureReviewEligibility) exec.SetOnAgentStartFailed(s.handleAgentStartFailed) if caps, ok := agentManager.(executor.ExecutorTypeCapabilities); ok { exec.SetCapabilities(caps) @@ -1157,6 +1167,12 @@ func (s *Service) SetRepoCloner(cloner executor.RepoCloner, updater executor.Rep s.executor.SetRepoCloner(cloner, updater) } +// SetTaskRepositoryBaseBranchUpdater wires exact task-repository fallback +// self-healing into the executor. +func (s *Service) SetTaskRepositoryBaseBranchUpdater(updater executor.TaskRepositoryBaseBranchUpdater) { + s.executor.SetTaskRepositoryBaseBranchUpdater(updater) +} + // RepositoryHostCloner is the narrow host-materialization contract. It returns // only the persisted local path; clone credentials remain private to the // orchestrator executor pipeline. @@ -2632,75 +2648,8 @@ func canResumeRunning(running *models.ExecutorRunning) bool { return models.IsAlwaysResumableRuntime(running.Runtime) } -func isMissingBranchError(err error) bool { - // A checked-out-elsewhere error chain can still mention "couldn't find - // remote ref" inside its concatenated fetch+checkout stderr, so the - // substring match below would misclassify it as a missing branch and - // post PR-recovery guidance for a branch that is in fact present - // locally. The local preparer wraps worktree.ErrBranchCheckedOut for - // that case; honor the typed sentinel first. - if errors.Is(err, worktree.ErrBranchCheckedOut) { - return false - } - for current := err; current != nil; current = errors.Unwrap(current) { - if isMissingBranchMessage(current.Error()) { - return true - } - } - return false -} - -func isMissingBranchMessage(message string) bool { - msg := strings.ToLower(message) - if strings.Contains(msg, "couldn't find remote ref") { - return true - } - if strings.Contains(msg, "pathspec") && strings.Contains(msg, "did not match") && - !strings.Contains(msg, "fetch branch failed") { - return true - } - - const missingBranchMarker = "not found locally or on remote" - markerIndex := strings.Index(msg, missingBranchMarker) - if markerIndex < 0 || !strings.Contains(msg[:markerIndex], "branch") { - return false - } - - // The worktree layer appends the underlying fetch failure after this marker. - // Only the marker by itself is evidence that the remote branch is missing. - detail := strings.TrimSpace(msg[markerIndex+len(missingBranchMarker):]) - return detail == "" -} - -var ( - quotedBranchPattern = regexp.MustCompile(`branch "([^"]+)"`) - remoteRefPattern = regexp.MustCompile(`remote ref ([^\s]+)`) - pathspecBranchPattern = regexp.MustCompile(`pathspec '([^']+)'`) -) - const launchFailurePRLookupTimeout = time.Second -func extractMissingBranchName(err error) string { - branch := "" - for current := err; current != nil; current = errors.Unwrap(current) { - msg := current.Error() - if match := quotedBranchPattern.FindStringSubmatch(msg); len(match) == 2 { - branch = strings.TrimSpace(match[1]) - continue - } - if match := remoteRefPattern.FindStringSubmatch(msg); len(match) == 2 { - branch = strings.TrimSpace(match[1]) - continue - } - if match := pathspecBranchPattern.FindStringSubmatch(msg); len(match) == 2 { - branch = strings.TrimSpace(match[1]) - } - } - return branch -} - -const missingPRBranchRecoveryClaimKey = "missing_pr_branch_recovery_claimed" - type failedSessionMetadataClaimer interface { SetSessionMetadataKeyIfAbsentIfState( ctx context.Context, @@ -2718,130 +2667,6 @@ type failedSessionMetadataClaimReleaser interface { ) (bool, error) } -func (s *Service) matchingTaskPRState(ctx context.Context, taskID, repositoryID, branch string) string { - repositoryID = strings.TrimSpace(repositoryID) - if s.githubService == nil || repositoryID == "" || branch == "" { - return "" - } - lookupCtx, cancel := context.WithTimeout(ctx, launchFailurePRLookupTimeout) - defer cancel() - prsByTask, err := s.githubService.ListTaskPRs(lookupCtx, []string{taskID}) - if err != nil { - s.logger.Debug("failed to load task PR state for missing branch guidance", - zap.String("task_id", taskID), - zap.Error(err)) - return "" - } - prs := prsByTask[taskID] - if state, found := taskPRState(prs, repositoryID, branch); found { - return state - } - state, found := taskPRState(prs, "", branch) - if !found || !s.hasOnlyTaskRepository(lookupCtx, taskID, repositoryID) { - return "" - } - return state -} - -func taskPRState(prs []*github.TaskPR, repositoryID, branch string) (string, bool) { - matched := false - state := "" - for _, pr := range prs { - if pr == nil || strings.TrimSpace(pr.RepositoryID) != repositoryID || strings.TrimSpace(pr.HeadBranch) != branch { - continue - } - if matched { - return "", true - } - matched = true - state = strings.ToLower(strings.TrimSpace(pr.State)) - if state != githubPRStateOpen && state != githubPRStateClosed && state != githubPRStateMerged { - return "", true - } - } - return state, matched -} - -func (s *Service) hasOnlyTaskRepository(ctx context.Context, taskID, repositoryID string) bool { - store, ok := s.repo.(interface { - ListTaskRepositories(context.Context, string) ([]*models.TaskRepository, error) - }) - if !ok { - return false - } - repositories, err := store.ListTaskRepositories(ctx, taskID) - if err != nil { - s.logger.Debug("failed to load task repositories for legacy PR guidance", - zap.String("task_id", taskID), - zap.Error(err)) - return false - } - return len(repositories) == 1 && repositories[0] != nil && - strings.TrimSpace(repositories[0].RepositoryID) == repositoryID -} - -// repositoryForMissingBranchFailure derives the failed repository from the -// task's checkout configuration when environment preparation failed before the -// executor constructed its repository-scoped launch request. It deliberately -// returns no match for an empty or ambiguous branch so destructive recovery -// actions remain scoped to the repository that actually failed. -func (s *Service) repositoryForMissingBranchFailure(ctx context.Context, taskID, branch string) (string, string) { - branch = strings.TrimSpace(branch) - if branch == "" { - return "", branch - } - store, ok := s.repo.(interface { - ListTaskRepositories(context.Context, string) ([]*models.TaskRepository, error) - }) - if !ok { - return "", branch - } - repositories, err := store.ListTaskRepositories(ctx, taskID) - if err != nil { - s.logger.Debug("failed to load task repositories for missing-branch guidance", - zap.String("task_id", taskID), zap.Error(err)) - return "", branch - } - - prNumber := missingBranchPRNumber(branch) - var repositoryID string - resolvedBranch := branch - for _, repository := range repositories { - if repository == nil || !taskRepositoryMatchesMissingBranch(repository, branch, prNumber) { - continue - } - if repositoryID != "" { - return "", branch - } - repositoryID = strings.TrimSpace(repository.RepositoryID) - if checkoutBranch := strings.TrimSpace(repository.CheckoutBranch); checkoutBranch != "" { - resolvedBranch = checkoutBranch - } - } - return repositoryID, resolvedBranch -} - -var missingBranchPRRefPattern = regexp.MustCompile(`^pull/(\d+)/head$`) - -func missingBranchPRNumber(branch string) int { - match := missingBranchPRRefPattern.FindStringSubmatch(strings.TrimSpace(branch)) - if len(match) != 2 { - return 0 - } - number, err := strconv.Atoi(match[1]) - if err != nil || number <= 0 { - return 0 - } - return number -} - -func taskRepositoryMatchesMissingBranch(repository *models.TaskRepository, branch string, prNumber int) bool { - if strings.TrimSpace(repository.CheckoutBranch) == branch { - return true - } - return prNumber > 0 && taskRepositoryPRNumber(repository.Metadata) == prNumber -} - func taskRepositoryPRNumber(metadata map[string]interface{}) int { if metadata == nil { return 0 @@ -2859,125 +2684,6 @@ func taskRepositoryPRNumber(metadata map[string]interface{}) int { return 0 } -// handleSessionLaunchFailed creates branch guidance only after the caller has -// persisted FAILED. The claim is stored on the session because prepare, start, -// and resume may race. -func (s *Service) handleSessionLaunchFailed(ctx context.Context, taskID, sessionID, repositoryID string, launchErr error) { - if s.messageCreator == nil || !isMissingBranchError(launchErr) { - return - } - recoveryCtx := context.WithoutCancel(ctx) - rawBranch := extractMissingBranchName(launchErr) - displayBranch := routingerr.Sanitize(rawBranch) - resolvedRepositoryID, resolvedBranch := s.repositoryForMissingBranchFailure(recoveryCtx, taskID, rawBranch) - if repositoryID == "" { - repositoryID = resolvedRepositoryID - } - if repositoryID != "" && repositoryID == resolvedRepositoryID { - rawBranch = resolvedBranch - displayBranch = routingerr.Sanitize(resolvedBranch) - } - prState := s.matchingTaskPRState(recoveryCtx, taskID, repositoryID, rawBranch) - if prState == githubPRStateOpen { - return - } - claimer, ok := s.repo.(failedSessionMetadataClaimer) - if !ok { - s.logger.Warn("session repository cannot claim missing-branch recovery", - zap.String("task_id", taskID), zap.String("session_id", sessionID)) - return - } - claimed, err := claimer.SetSessionMetadataKeyIfAbsentIfState( - recoveryCtx, sessionID, missingPRBranchRecoveryClaimKey, true, models.TaskSessionStateFailed, - ) - if err != nil { - s.logger.Warn("failed to claim missing-branch recovery", - zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.Error(err)) - return - } - if !claimed { - return - } - if err := s.createMissingPRBranchRecoveryMessage( - recoveryCtx, taskID, sessionID, displayBranch, prState, - ); err != nil { - releaser, ok := s.repo.(failedSessionMetadataClaimReleaser) - if !ok { - s.logger.Warn("session repository cannot release missing-branch recovery claim after message failure", - zap.String("task_id", taskID), zap.String("session_id", sessionID)) - return - } - if _, releaseErr := releaser.RemoveSessionMetadataKeyIfState( - recoveryCtx, sessionID, missingPRBranchRecoveryClaimKey, models.TaskSessionStateFailed, - ); releaseErr != nil { - s.logger.Warn("failed to release missing-branch recovery claim after message failure", - zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.Error(releaseErr)) - } - } -} - -func (s *Service) createMissingPRBranchRecoveryMessage( - ctx context.Context, - taskID, sessionID, branch, prState string, -) error { - content := "Kandev couldn't fetch the requested branch from the configured repository. Verify the task's repository branch or PR link, then retry." - if branch != "" { - content = "Kandev couldn't fetch branch \"" + branch + "\" from the configured repository. Verify the task's repository branch or PR link, then retry." - } - authoritativeMissingBranch := prState == githubPRStateClosed || prState == githubPRStateMerged - if authoritativeMissingBranch { - content = "This task references a PR branch that no longer exists on remote." - if branch != "" { - content = "The remote PR branch \"" + branch + "\" no longer exists." - } - } - metadata := map[string]interface{}{ - "variant": "warning", - "failure_kind": "branch_fetch_failed", - "missing_branch": branch, - } - if authoritativeMissingBranch { - metadata["failure_kind"] = "missing_pr_branch" - metadata["actions"] = []map[string]interface{}{ - { - actionMetaKeyType: "archive_task", - actionMetaKeyLabel: "Archive task", - actionMetaKeyTooltip: "Keep task history and hide it from active work", - actionMetaKeyIcon: "archive", - actionMetaKeyTestID: "missing-branch-archive-button", - }, - { - actionMetaKeyType: "delete_task", - actionMetaKeyLabel: "Delete task", - actionMetaKeyTooltip: "Permanently remove this task", - "variant": "destructive", - actionMetaKeyIcon: "trash", - actionMetaKeyTestID: "missing-branch-delete-button", - }, - } - } - msgCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - if err := s.messageCreator.CreateSessionMessage( - msgCtx, - taskID, - content, - sessionID, - string(v1.MessageTypeStatus), - "", // No turn — agent never started, avoid lazily creating a synthetic turn. - metadata, - false, - ); err != nil { - s.logger.Warn("failed to create missing PR branch launch failure message", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.Error(err)) - return err - } - s.suppressToast.Store(sessionID, true) - return nil -} - // IsRunning returns true if the service is running func (s *Service) IsRunning() bool { s.mu.RLock() diff --git a/apps/backend/internal/orchestrator/session_ensure.go b/apps/backend/internal/orchestrator/session_ensure.go index 2ed7694655..df503b8a2b 100644 --- a/apps/backend/internal/orchestrator/session_ensure.go +++ b/apps/backend/internal/orchestrator/session_ensure.go @@ -17,7 +17,7 @@ type EnsureSessionResponse struct { SessionID string `json:"session_id,omitempty"` State string `json:"state"` AgentProfileID string `json:"agent_profile_id,omitempty"` - Source string `json:"source"` // existing_primary | existing_newest | created_prepare | created_start + Source string `json:"source"` // existing_primary | existing_newest | created_prepare | created_start | skipped_terminal_pr NewlyCreated bool `json:"newly_created"` // true when a new session was created by this call WorkspacePath string `json:"workspace_path,omitempty"` // effective workspace path (for quick-chat sessions without worktrees) } @@ -117,6 +117,22 @@ func (s *Service) EnsureSession(ctx context.Context, taskID string, opts ...Ensu if err != nil { return nil, err } + if launchResp == nil { + return nil, fmt.Errorf("session launch returned no response") + } + if launchResp.SessionID == "" { + // An automatic launch can be intentionally skipped after the task is + // found to have a terminal pull request. Keep ensure idempotent and let + // the task-owned launch error card render without creating a session. + return &EnsureSessionResponse{ + Success: true, + TaskID: taskID, + State: launchResp.State, + AgentProfileID: agentProfileID, + Source: "skipped_terminal_pr", + NewlyCreated: false, + }, nil + } return &EnsureSessionResponse{ Success: true, diff --git a/apps/backend/internal/orchestrator/session_launch.go b/apps/backend/internal/orchestrator/session_launch.go index 86233d12c5..62da2ad603 100644 --- a/apps/backend/internal/orchestrator/session_launch.go +++ b/apps/backend/internal/orchestrator/session_launch.go @@ -257,6 +257,16 @@ func (s *Service) launchStart(ctx context.Context, req *LaunchSessionRequest) (* if err != nil { return nil, err } + if execution == nil { + // The automatic terminal-PR gate intentionally skips session creation. + // Return a successful no-op response so session.ensure and WS callers do + // not dereference a nil execution while the task-owned error card remains + // the recovery surface. + return &LaunchSessionResponse{ + Success: true, + TaskID: req.TaskID, + }, nil + } return executionToLaunchResponse(req.TaskID, execution), nil } @@ -424,6 +434,12 @@ func isMissingProfileResumeError(err error) bool { // executionToLaunchResponse converts a TaskExecution to a LaunchSessionResponse. func executionToLaunchResponse(taskID string, exec *executor.TaskExecution) *LaunchSessionResponse { + if exec == nil { + return &LaunchSessionResponse{ + Success: true, + TaskID: taskID, + } + } resp := &LaunchSessionResponse{ Success: true, TaskID: taskID, diff --git a/apps/backend/internal/orchestrator/session_launch_test.go b/apps/backend/internal/orchestrator/session_launch_test.go index 531ee03ca2..54f19c6777 100644 --- a/apps/backend/internal/orchestrator/session_launch_test.go +++ b/apps/backend/internal/orchestrator/session_launch_test.go @@ -27,6 +27,26 @@ func TestExecutionToLaunchResponseReportsAgentProfile(t *testing.T) { } } +func TestExecutionToLaunchResponseHandlesNilExecution(t *testing.T) { + // A guarded automatic launch can return a successful no-op without an + // execution. The response helper is defensive for callers that use it + // directly or through another launch path. + response := executionToLaunchResponse("task-1", nil) + if response == nil { + t.Fatal("expected a response") + return + } + if !response.Success { + t.Fatal("expected a successful no-op response") + } + if response.TaskID != "task-1" { + t.Fatalf("task_id = %q, want task-1", response.TaskID) + } + if response.SessionID != "" { + t.Fatalf("session_id = %q, want empty", response.SessionID) + } +} + func TestResolveIntent(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/orchestrator/task_launch_failure_test.go b/apps/backend/internal/orchestrator/task_launch_failure_test.go index 509a949a91..bb1aa197d3 100644 --- a/apps/backend/internal/orchestrator/task_launch_failure_test.go +++ b/apps/backend/internal/orchestrator/task_launch_failure_test.go @@ -3,783 +3,41 @@ package orchestrator import ( "context" "errors" - "fmt" - "strings" "testing" - "time" - "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/task/models" - "github.com/kandev/kandev/internal/worktree" v1 "github.com/kandev/kandev/pkg/api/v1" ) -func TestIsMissingBranchError(t *testing.T) { - tests := []struct { - name string - err error - want bool - }{ - { - name: "remote ref missing", - err: errors.New("environment preparation failed: fatal: couldn't find remote ref feature/foo"), - want: true, - }, - { - name: "branch not found locally or remote", - err: errors.New("environment preparation failed: branch \"feature/foo\" not found locally or on remote"), - want: true, - }, - { - name: "pathspec did not match (local executor)", - err: errors.New("environment preparation failed: checkout branch: git command failed: error: pathspec 'feature/foo' did not match any file(s) known to git"), - want: true, - }, - { - name: "unrelated launch error", - err: errors.New("failed to launch container"), - want: false, - }, - { - name: "nil error", - err: nil, - want: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := isMissingBranchError(tt.err); got != tt.want { - t.Fatalf("isMissingBranchError()=%v, want %v", got, tt.want) - } - }) - } -} - -func TestExtractMissingBranchName(t *testing.T) { - tests := []struct { - name string - err error - want string - }{ - { - name: "quoted branch form", - err: errors.New("environment preparation failed: branch \"feature/foo\" not found locally or on remote"), - want: "feature/foo", - }, - { - name: "remote ref form", - err: errors.New("fatal: couldn't find remote ref hotfix/bar"), - want: "hotfix/bar", - }, - { - name: "pathspec form (local executor)", - err: errors.New("checkout branch: git command failed: error: pathspec 'feature/baz' did not match any file(s) known to git"), - want: "feature/baz", - }, - { - name: "no branch available", - err: errors.New("failed to launch container"), - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := extractMissingBranchName(tt.err); got != tt.want { - t.Fatalf("extractMissingBranchName()=%q, want %q", got, tt.want) - } - }) - } -} - -func TestHandleSessionLaunchFailed_UnavailablePRStateCreatesNeutralFetchGuidance(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateStarting) - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - svc.SetGitHubService(&mockGitHubService{taskPRErr: errors.New("persisted PR state unavailable")}) - - err := errors.New("environment preparation failed: branch \"feature/foo\" not found locally or on remote: fatal: couldn't find remote ref feature/foo") - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-a", err) - if len(mc.sessionMessages) != 0 { - t.Fatalf("launch callback created guidance before the FAILED transition: %d messages", len(mc.sessionMessages)) - } - if err := repo.UpdateTaskSessionState(ctx, "session1", models.TaskSessionStateFailed, err.Error()); err != nil { - t.Fatalf("mark session failed: %v", err) - } - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-a", err) - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-a", err) - - if len(mc.sessionMessages) != 1 { - t.Fatalf("expected 1 session message, got %d", len(mc.sessionMessages)) - } - msg := mc.sessionMessages[0] - if msg.taskID != "task1" || msg.sessionID != "session1" { - t.Fatalf("unexpected message target: task=%s session=%s", msg.taskID, msg.sessionID) - } - if msg.messageType != string(v1.MessageTypeStatus) { - t.Fatalf("expected status message type, got %q", msg.messageType) - } - if msg.turnID != "" { - t.Fatalf("expected empty turn ID for pre-start failure, got %q", msg.turnID) - } - lowerContent := strings.ToLower(msg.content) - if !strings.Contains(msg.content, "feature/foo") || !strings.Contains(lowerContent, "retry") || strings.Contains(lowerContent, "merged") { - t.Fatalf("expected actionable content with branch name and no unverified merge claim, got %q", msg.content) - } - if kind, ok := msg.metadata["failure_kind"].(string); !ok || kind != "branch_fetch_failed" { - t.Fatalf("expected failure_kind metadata, got %#v", msg.metadata["failure_kind"]) - } - if branch, ok := msg.metadata["missing_branch"].(string); !ok || branch != "feature/foo" { - t.Fatalf("expected missing_branch metadata, got %#v", msg.metadata["missing_branch"]) - } - if _, ok := msg.metadata["actions"]; ok { - t.Fatalf("expected neutral guidance without archive/delete actions, got %#v", msg.metadata["actions"]) - } -} - -func TestHandleSessionLaunchFailed_OpenMatchingPRDoesNotCreateMissingBranchGuidance(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{ - {TaskID: "task1", RepositoryID: "repo-a", HeadBranch: "feature/foo", State: "open"}, - }}) - - err := errors.New("environment preparation failed: branch \"feature/foo\" not found locally or on remote") - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-a", err) - - if len(mc.sessionMessages) != 0 { - t.Fatalf("expected no persistent missing-branch guidance for an open PR, got %d messages", len(mc.sessionMessages)) - } - if _, ok := svc.suppressToast.Load("session1"); ok { - t.Fatal("expected the ordinary launch error path to remain available") - } -} - -func TestHandleSessionLaunchFailed_SecondaryOpenPRDoesNotUsePrimaryTerminalPR(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{ - {TaskID: "task1", RepositoryID: "repo-primary", HeadBranch: "feature/foo", State: "closed"}, - {TaskID: "task1", RepositoryID: "repo-secondary", HeadBranch: "feature/foo", State: "open"}, - }}) - - err := errors.New("environment preparation failed: branch \"feature/foo\" not found locally or on remote") - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-secondary", err) - - if len(mc.sessionMessages) != 0 { - t.Fatalf("expected no missing-branch guidance for the secondary repository's open PR, got %d messages", len(mc.sessionMessages)) - } -} - -func TestHandleSessionLaunchFailed_BoundsBlockingPRStateLookup(t *testing.T) { - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - var lookupHadDeadline bool - svc.SetGitHubService(&mockGitHubService{taskPRListFunc: func(ctx context.Context, _ []string) (map[string][]*github.TaskPR, error) { - _, lookupHadDeadline = ctx.Deadline() - if !lookupHadDeadline { - return nil, errors.New("missing lookup deadline") - } - <-ctx.Done() - return nil, ctx.Err() - }}) - - started := time.Now() - err := errors.New("environment preparation failed: branch \"feature/foo\" not found locally or on remote") - svc.handleSessionLaunchFailed(context.Background(), "task1", "session1", "repo-a", err) - - if !lookupHadDeadline { - t.Fatal("expected task PR lookup to have a deadline") - } - if elapsed := time.Since(started); elapsed > 1500*time.Millisecond { - t.Fatalf("blocking task PR lookup took %s, want no more than 1.5s", elapsed) - } - if len(mc.sessionMessages) != 1 { - t.Fatalf("expected neutral guidance after task PR lookup timeout, got %d messages", len(mc.sessionMessages)) - } - if _, ok := mc.sessionMessages[0].metadata["actions"]; ok { - t.Fatalf("expected no destructive actions after task PR lookup timeout, got %#v", mc.sessionMessages[0].metadata["actions"]) - } -} - -func TestHandleSessionLaunchFailed_ConflictingSameBranchPRStatesCreateNeutralGuidance(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{ - {TaskID: "task1", RepositoryID: "repo-a", HeadBranch: "feature/foo", State: "open"}, - {TaskID: "task1", RepositoryID: "repo-a", HeadBranch: "feature/foo", State: "closed"}, - }}) - - err := errors.New("environment preparation failed: branch \"feature/foo\" not found locally or on remote") - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-a", err) - - if len(mc.sessionMessages) != 1 { - t.Fatalf("expected neutral guidance for ambiguous PR state, got %d messages", len(mc.sessionMessages)) - } - msg := mc.sessionMessages[0] - if !strings.Contains(strings.ToLower(msg.content), "retry") || strings.Contains(msg.content, "no longer exists") { - t.Fatalf("expected neutral fetch-failure guidance, got %q", msg.content) - } - if kind := msg.metadata["failure_kind"]; kind != "branch_fetch_failed" { - t.Fatalf("expected neutral failure kind, got %#v", kind) - } - if _, ok := msg.metadata["actions"]; ok { - t.Fatalf("expected no destructive actions for ambiguous PR state, got %#v", msg.metadata["actions"]) - } -} - -func TestHandleSessionLaunchFailed_MultipleTerminalMatchingPRsCreateNeutralGuidance(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{ - {TaskID: "task1", RepositoryID: "repo-a", HeadBranch: "feature/foo", State: "closed"}, - {TaskID: "task1", RepositoryID: "repo-a", HeadBranch: "feature/foo", State: "merged"}, - }}) - - err := errors.New("environment preparation failed: branch \"feature/foo\" not found locally or on remote") - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-a", err) - - if len(mc.sessionMessages) != 1 { - t.Fatalf("expected neutral guidance for ambiguous terminal PRs, got %d messages", len(mc.sessionMessages)) - } - msg := mc.sessionMessages[0] - if !strings.Contains(strings.ToLower(msg.content), "retry") || strings.Contains(msg.content, "no longer exists") { - t.Fatalf("expected neutral fetch-failure guidance, got %q", msg.content) - } - if kind := msg.metadata["failure_kind"]; kind != "branch_fetch_failed" { - t.Fatalf("expected neutral failure kind, got %#v", kind) - } - if _, ok := msg.metadata["actions"]; ok { - t.Fatalf("expected no destructive actions for ambiguous terminal PRs, got %#v", msg.metadata["actions"]) - } -} - -func TestHandleSessionLaunchFailed_ClosedOrMergedMatchingPRCreatesMissingBranchGuidance(t *testing.T) { - for _, prState := range []string{"closed", "merged"} { - t.Run(prState, func(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{ - {TaskID: "task1", RepositoryID: "repo-a", HeadBranch: "feature/foo", State: prState}, - }}) - - err := errors.New("environment preparation failed: branch \"feature/foo\" not found locally or on remote") - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-a", err) - - if len(mc.sessionMessages) != 1 { - t.Fatalf("expected 1 missing-branch guidance message, got %d", len(mc.sessionMessages)) - } - msg := mc.sessionMessages[0] - if !strings.Contains(msg.content, "feature/foo") || !strings.Contains(msg.content, "no longer exists") { - t.Fatalf("expected authoritative missing-branch guidance, got %q", msg.content) - } - actions, ok := msg.metadata["actions"].([]map[string]interface{}) - if !ok || len(actions) != 2 { - t.Fatalf("expected archive/delete actions, got %#v", msg.metadata["actions"]) - } - if suppressed, ok := svc.suppressToast.Load("session1"); !ok || suppressed != true { - t.Fatalf("expected duplicate launch toast to be suppressed, got ok=%v value=%v", ok, suppressed) - } - }) - } -} - -func TestHandleSessionLaunchFailure_ResolvesRepositoryForEarlyMissingBranchFailure(t *testing.T) { +func TestHandledLaunchFailureLeavesTypedErrorOwnerWithoutLegacyGuidance(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCreated) - if err := repo.CreateRepository(ctx, &models.Repository{ - ID: "repo-a", WorkspaceID: "ws1", Name: "repo-a", SourceType: "local", - }); err != nil { - t.Fatalf("create repository: %v", err) - } - if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ - ID: "task-repo-a", - TaskID: "task1", - RepositoryID: "repo-a", - CheckoutBranch: "feature/already-merged-and-deleted", - Position: 0, - Metadata: map[string]interface{}{"pr_number": 999}, - }); err != nil { - t.Fatalf("create task repository: %v", err) - } messages := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + taskRepo := newMockTaskRepo() + taskRepo.tasks["task1"] = &v1.Task{ID: "task1", State: v1.TaskStateInProgress} + svc := createTestService(repo, newMockStepGetter(), taskRepo) svc.messageCreator = messages - svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{ - {TaskID: "task1", RepositoryID: "repo-a", HeadBranch: "feature/already-merged-and-deleted", State: githubPRStateClosed}, - }}) - - _ = svc.handleSessionLaunchFailure( - ctx, - "task1", - "session1", - errors.New("environment preparation failed: branch \"feature/already-merged-and-deleted\" not found locally or on remote: fatal: couldn't find remote ref pull/999/head"), - ) - - session, err := repo.GetTaskSession(ctx, "session1") - if err != nil { - t.Fatalf("get failed session: %v", err) - } - if session.State != models.TaskSessionStateFailed { - t.Fatalf("session state = %q, want FAILED", session.State) - } - if strings.Contains(session.ErrorMessage, "feature/already-merged-and-deleted") { - t.Fatalf("persisted error exposed the raw branch: %q", session.ErrorMessage) + err := errors.New("environment preparation failed: fatal: couldn't find remote ref feature/deleted") + if got := svc.handleSessionLaunchFailure(ctx, "task1", "session1", err); !errors.Is(got, err) { + t.Fatalf("handleSessionLaunchFailure error = %v, want %v", got, err) } - if len(messages.sessionMessages) != 1 { - t.Fatalf("expected one missing-branch recovery message, got %d", len(messages.sessionMessages)) - } - if kind := messages.sessionMessages[0].metadata["failure_kind"]; kind != "missing_pr_branch" { - t.Fatalf("failure_kind = %#v, want missing_pr_branch", kind) - } - if branch := messages.sessionMessages[0].metadata["missing_branch"]; branch != "***" { - t.Fatalf("missing_branch = %#v, want sanitized branch", branch) - } -} - -func TestHandleSessionLaunchFailed_UnrelatedOpenPRDoesNotSuppressNeutralGuidance(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{ - {TaskID: "task1", RepositoryID: "repo-a", HeadBranch: "feature/other", State: "open"}, - }}) - - err := errors.New("environment preparation failed: branch \"feature/foo\" not found locally or on remote") - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-a", err) - - if len(mc.sessionMessages) != 1 { - t.Fatalf("expected neutral guidance for the unmatched branch, got %d messages", len(mc.sessionMessages)) - } - msg := mc.sessionMessages[0] - if !strings.Contains(strings.ToLower(msg.content), "retry") || strings.Contains(msg.content, "no longer exists") { - t.Fatalf("expected neutral fetch-failure guidance, got %q", msg.content) - } - if kind := msg.metadata["failure_kind"]; kind != "branch_fetch_failed" { - t.Fatalf("expected neutral failure kind, got %#v", kind) - } -} - -func TestHandleSessionLaunchFailed_ClosedPRInAnotherRepositoryCreatesNeutralGuidance(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{ - {TaskID: "task1", RepositoryID: "repo-a", HeadBranch: "feature/foo", State: "closed"}, - }}) - - err := errors.New("environment preparation failed: branch \"feature/foo\" not found locally or on remote") - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-b", err) - - if len(mc.sessionMessages) != 1 { - t.Fatalf("expected neutral guidance when repo-b launch matches only repo-a's closed PR, got %d messages", len(mc.sessionMessages)) - } - msg := mc.sessionMessages[0] - if !strings.Contains(strings.ToLower(msg.content), "retry") || strings.Contains(msg.content, "no longer exists") { - t.Fatalf("expected neutral fetch-failure guidance, got %q", msg.content) - } - if kind := msg.metadata["failure_kind"]; kind != "branch_fetch_failed" { - t.Fatalf("expected neutral failure kind, got %#v", kind) - } - if _, ok := msg.metadata["actions"]; ok { - t.Fatalf("expected no destructive actions without a repository-scoped PR match, got %#v", msg.metadata["actions"]) - } -} - -func TestHandleSessionLaunchFailed_SingleRepositoryLegacyPRCreatesMissingBranchGuidance(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - if err := repo.CreateRepository(ctx, &models.Repository{ - ID: "repo-a", WorkspaceID: "ws1", Name: "repo-a", SourceType: "local", - }); err != nil { - t.Fatalf("create repository: %v", err) - } - if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ - ID: "task-repo-a", TaskID: "task1", RepositoryID: "repo-a", Position: 0, - }); err != nil { - t.Fatalf("link task repository: %v", err) - } - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{ - {TaskID: "task1", HeadBranch: "feature/foo", State: "closed"}, - }}) - - err := errors.New("environment preparation failed: branch \"feature/foo\" not found locally or on remote") - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-a", err) - - if len(mc.sessionMessages) != 1 { - t.Fatalf("expected 1 missing-branch guidance message, got %d", len(mc.sessionMessages)) - } - msg := mc.sessionMessages[0] - if !strings.Contains(msg.content, "feature/foo") || !strings.Contains(msg.content, "no longer exists") { - t.Fatalf("expected authoritative missing-branch guidance, got %q", msg.content) - } - if actions, ok := msg.metadata["actions"].([]map[string]interface{}); !ok || len(actions) != 2 { - t.Fatalf("expected archive/delete actions, got %#v", msg.metadata["actions"]) - } -} - -func TestHandleSessionLaunchFailed_LegacyPRForDifferentRepositoryCreatesNeutralGuidance(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - if err := repo.CreateRepository(ctx, &models.Repository{ - ID: "repo-a", WorkspaceID: "ws1", Name: "repo-a", SourceType: "local", - }); err != nil { - t.Fatalf("create repository: %v", err) - } - if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ - ID: "task-repo-a", TaskID: "task1", RepositoryID: "repo-a", Position: 0, - }); err != nil { - t.Fatalf("link task repository: %v", err) - } - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{ - {TaskID: "task1", HeadBranch: "feature/foo", State: "closed"}, - }}) - - err := errors.New("environment preparation failed: branch \"feature/foo\" not found locally or on remote") - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-b", err) - - if len(mc.sessionMessages) != 1 { - t.Fatalf("expected 1 neutral fetch-failure guidance message, got %d", len(mc.sessionMessages)) - } - msg := mc.sessionMessages[0] - if kind := msg.metadata["failure_kind"]; kind != "branch_fetch_failed" { - t.Fatalf("expected neutral failure kind, got %#v", kind) - } - if _, ok := msg.metadata["actions"]; ok { - t.Fatalf("expected no destructive actions for a legacy PR linked to another repository, got %#v", msg.metadata["actions"]) - } -} - -func TestHandleSessionLaunchFailed_MultipleRepositoriesLegacyPRCreatesNeutralGuidance(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - for position, repositoryID := range []string{"repo-a", "repo-b"} { - if err := repo.CreateRepository(ctx, &models.Repository{ - ID: repositoryID, WorkspaceID: "ws1", Name: repositoryID, SourceType: "local", - }); err != nil { - t.Fatalf("create repository %q: %v", repositoryID, err) - } - if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ - ID: "task-repo-" + repositoryID, TaskID: "task1", RepositoryID: repositoryID, Position: position, - }); err != nil { - t.Fatalf("link task repository %q: %v", repositoryID, err) - } - } - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{ - {TaskID: "task1", HeadBranch: "feature/foo", State: "closed"}, - }}) - - err := errors.New("environment preparation failed: branch \"feature/foo\" not found locally or on remote") - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-a", err) - - if len(mc.sessionMessages) != 1 { - t.Fatalf("expected 1 neutral fetch-failure guidance message, got %d", len(mc.sessionMessages)) - } - msg := mc.sessionMessages[0] - if kind := msg.metadata["failure_kind"]; kind != "branch_fetch_failed" { - t.Fatalf("expected neutral failure kind, got %#v", kind) - } - if _, ok := msg.metadata["actions"]; ok { - t.Fatalf("expected no destructive actions for an unscoped multi-repository PR, got %#v", msg.metadata["actions"]) - } -} - -func TestHandleSessionLaunchFailed_EmptyRepositoryIDCreatesNeutralGuidance(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{ - {TaskID: "task1", RepositoryID: "repo-a", HeadBranch: "feature/foo", State: "closed"}, - }}) - - err := errors.New("environment preparation failed: branch \"feature/foo\" not found locally or on remote") - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "", err) - - if len(mc.sessionMessages) != 1 { - t.Fatalf("expected neutral guidance without a failed repository ID, got %d messages", len(mc.sessionMessages)) - } - msg := mc.sessionMessages[0] - if !strings.Contains(strings.ToLower(msg.content), "retry") || strings.Contains(msg.content, "no longer exists") { - t.Fatalf("expected neutral fetch-failure guidance, got %q", msg.content) - } - if kind := msg.metadata["failure_kind"]; kind != "branch_fetch_failed" { - t.Fatalf("expected neutral failure kind, got %#v", kind) - } - if _, ok := msg.metadata["actions"]; ok { - t.Fatalf("expected no destructive actions without a repository-scoped PR match, got %#v", msg.metadata["actions"]) - } -} - -func TestHandleSessionLaunchFailed_RetriesAfterGuidanceMessageWriteFailure(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - - writeErr := errors.New("message store unavailable") - mc := &mockMessageCreator{sessionMessageErr: writeErr} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - err := errors.New("environment preparation failed: fatal: couldn't find remote ref feature/foo") - - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "", err) - if len(mc.sessionMessages) != 0 { - t.Fatalf("failed message write persisted %d messages", len(mc.sessionMessages)) + if len(messages.sessionMessages) != 0 { + t.Fatalf("legacy launch guidance messages = %d, want 0", len(messages.sessionMessages)) } if _, suppressed := svc.suppressToast.Load("session1"); suppressed { - t.Fatal("failed message write suppressed the fallback toast") + t.Fatal("typed launch failure must not suppress the pointer toast") } - failed, getErr := repo.GetTaskSession(ctx, "session1") + session, getErr := repo.GetTaskSession(ctx, "session1") if getErr != nil { t.Fatalf("get failed session: %v", getErr) } - if _, claimed := failed.Metadata[missingPRBranchRecoveryClaimKey]; claimed { - t.Fatal("failed message write left the recovery claim persisted") + if session.State != models.TaskSessionStateFailed { + t.Fatalf("session state = %s, want FAILED", session.State) } - - mc.sessionMessageErr = nil - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "", err) - if len(mc.sessionMessages) != 1 { - t.Fatalf("retry created %d messages, want 1", len(mc.sessionMessages)) - } - if _, suppressed := svc.suppressToast.Load("session1"); !suppressed { - t.Fatal("successful recovery message did not suppress the fallback toast") - } -} - -func TestHandleSessionLaunchFailed_IgnoresUnrelatedErrors(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-a", errors.New("failed to launch container")) - - if len(mc.sessionMessages) != 0 { - t.Fatalf("expected no session messages for unrelated error, got %d", len(mc.sessionMessages)) - } -} - -func TestHandleSessionLaunchFailed_IgnoresMissingBranchWrapperAroundTransportError(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - - err := errors.New("environment preparation failed: branch \"codex/enhance-prompt-result-delivery\" not found locally or on remote: fatal: unable to access 'https://github.com/kdlbs/kandev.git/': Could not resolve host: github.com") - if isMissingBranchError(err) { - t.Fatal("expected transport failure not to be classified as a missing PR branch") - } - - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "", err) - - if len(mc.sessionMessages) != 0 { - t.Fatalf("expected no guidance message for transport failure, got %d", len(mc.sessionMessages)) - } -} - -func TestHandleSessionLaunchFailed_IgnoresPathspecAfterFetchFailure(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - - err := errors.New("environment preparation failed: checkout branch: git command failed: fetch branch failed: exit status 128: fatal: unable to access 'https://github.com/kdlbs/kandev.git/': Could not resolve host: github.com\ncheckout branch failed: error: pathspec 'codex/enhance-prompt-result-delivery' did not match any file(s) known to git") - if isMissingBranchError(err) { - t.Fatal("expected pathspec after fetch failure not to be classified as a missing PR branch") - } - - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "", err) - - if len(mc.sessionMessages) != 0 { - t.Fatalf("expected no guidance message after fetch failure, got %d", len(mc.sessionMessages)) - } -} - -// branchCheckedOutElsewhereCombinedError reproduces the error chain that the -// local environment preparer produces when a target branch is already checked -// out in another worktree and the remote no longer carries the ref. Both -// stderr fragments end up concatenated in a single error string: -// -// - "fetch branch failed: ... couldn't find remote ref ..." (from the fetch -// step) -// - "checkout branch failed: ... is already checked out at ..." (from the -// checkout step that ClassifyGitError maps to ErrBranchCheckedOut) -// -// The wrapping layers are "checkout branch: " and finally -// "environment preparation failed: " added by the launch manager. -func branchCheckedOutElsewhereCombinedError(branch, siblingPath string) error { - combined := fmt.Sprintf( - "fetch branch failed: exit status 128: fatal: couldn't find remote ref %s\n"+ - "checkout branch failed: fatal: '%s' is already checked out at '%s'", - branch, branch, siblingPath, - ) - return fmt.Errorf("environment preparation failed: checkout branch: %w: %s", - fmt.Errorf("%w: %s", worktree.ErrBranchCheckedOut, combined), - combined, - ) -} - -// TestIsMissingBranchError_DoesNotMatchCheckedOutElsewhere is the -// orchestrator-level regression for the local-preparer misclassification. -// The combined fetch+checkout error contains "couldn't find remote ref" -// inside its message, but the chain wraps worktree.ErrBranchCheckedOut — -// isMissingBranchError must not misclassify it as a missing-branch failure. -func TestIsMissingBranchError_DoesNotMatchCheckedOutElsewhere(t *testing.T) { - err := branchCheckedOutElsewhereCombinedError("feature/shared", "/tmp/sibling") - if !errors.Is(err, worktree.ErrBranchCheckedOut) { - t.Fatalf("expected chain to wrap worktree.ErrBranchCheckedOut, got %v", err) - } - if !strings.Contains(err.Error(), "couldn't find remote ref") { - t.Fatalf("expected combined message to contain fetch stderr, got %v", err) - } - if isMissingBranchError(err) { - t.Fatalf("isMissingBranchError misclassified the checked-out-elsewhere error as a missing branch: %v", err) - } -} - -// TestExtractMissingBranchName_ExtractsCombinedDiagnostic documents that the -// extractor still returns the branch from a combined fetch and checkout -// diagnostic. The caller must classify the typed sentinel before using it. -func TestExtractMissingBranchName_ExtractsCombinedDiagnostic(t *testing.T) { - err := branchCheckedOutElsewhereCombinedError("feature/shared", "/tmp/sibling") - if got := extractMissingBranchName(err); got != "feature/shared" { - t.Fatalf("extractMissingBranchName() = %q, want %q", got, "feature/shared") - } -} - -// TestHandleSessionLaunchFailed_BranchCheckedOutElsewhereDoesNotCreateFetchGuidance -// is the end-to-end orchestrator regression. When the launch manager surfaces -// a checked-out-elsewhere error, the orchestrator must not post a -// branch_fetch_failed / missing-branch guidance message — the branch is not -// missing, only checked out in another worktree. -func TestHandleSessionLaunchFailed_BranchCheckedOutElsewhereDoesNotCreateFetchGuidance(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - svc.SetGitHubService(&mockGitHubService{}) - - err := branchCheckedOutElsewhereCombinedError("feature/shared", "/tmp/sibling") - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-a", err) - - if len(mc.sessionMessages) != 0 { - var kinds []string - for _, m := range mc.sessionMessages { - if k, ok := m.metadata["failure_kind"].(string); ok { - kinds = append(kinds, k) - } - } - t.Fatalf("expected no session guidance for checked-out-elsewhere error, got %d messages (failure_kinds=%v)", - len(mc.sessionMessages), kinds) - } - if _, ok := svc.suppressToast.Load("session1"); ok { - t.Fatal("expected no missing-branch toast suppression for a checked-out-elsewhere error") - } -} - -// TestHandleSessionLaunchFailed_StillCreatesFetchGuidanceForRealMissingBranch -// pins the positive direction: a genuine missing-branch error (no -// ErrBranchCheckedOut in the chain) must still produce the -// branch_fetch_failed guidance. The orchestrator fix must not over-correct. -func TestHandleSessionLaunchFailed_StillCreatesFetchGuidanceForRealMissingBranch(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - - mc := &mockMessageCreator{} - svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.messageCreator = mc - svc.SetGitHubService(&mockGitHubService{}) - - // Plain "couldn't find remote ref" without any ErrBranchCheckedOut — the - // remote branch really is gone and the local branch does not exist - // either, so the guidance must still fire. - err := errors.New("environment preparation failed: branch \"feature/deleted\" not found locally or on remote: fatal: couldn't find remote ref feature/deleted") - if !isMissingBranchError(err) { - t.Fatalf("expected genuine missing-branch error to still be classified, got %v", err) - } - - svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-a", err) - - if len(mc.sessionMessages) != 1 { - t.Fatalf("expected 1 guidance message for genuine missing branch, got %d", len(mc.sessionMessages)) - } - if kind, ok := mc.sessionMessages[0].metadata["failure_kind"].(string); !ok || kind != "branch_fetch_failed" { - t.Fatalf("expected failure_kind=branch_fetch_failed, got %#v", mc.sessionMessages[0].metadata["failure_kind"]) + if _, claimed := session.Metadata["missing_pr_branch_recovery_claimed"]; claimed { + t.Fatal("legacy missing-branch claim must not be written") } } diff --git a/apps/backend/internal/orchestrator/task_launch_pr_gate.go b/apps/backend/internal/orchestrator/task_launch_pr_gate.go new file mode 100644 index 0000000000..1de3c6302a --- /dev/null +++ b/apps/backend/internal/orchestrator/task_launch_pr_gate.go @@ -0,0 +1,350 @@ +package orchestrator + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/task/models" +) + +const ( + terminalPRLaunchMessage = "The linked pull request is already closed or merged." +) + +type taskPRMatch struct { + taskRepositoryID string + pr *github.TaskPR +} + +// selectRelevantTaskPRs matches PR links to the exact task-repository row that +// would launch the work. A persisted PR number is authoritative; branch +// matching is only a compatibility fallback for rows without that identity. +func selectRelevantTaskPRs( + taskRepositories []*models.TaskRepository, + prs []*github.TaskPR, +) []taskPRMatch { + var matches []taskPRMatch + for _, taskRepository := range taskRepositories { + if taskRepository == nil { + continue + } + prNumber := taskRepositoryPRNumber(taskRepository.Metadata) + if prNumber > 0 { + for _, pr := range prs { + if pr == nil || pr.RepositoryID != taskRepository.RepositoryID || pr.PRNumber != prNumber { + continue + } + matches = append(matches, taskPRMatch{ + taskRepositoryID: taskRepository.ID, + pr: pr, + }) + } + continue + } + + checkoutBranch := normalizeTaskPRBranch(taskRepository.CheckoutBranch) + if checkoutBranch == "" { + continue + } + for _, pr := range prs { + if pr == nil || pr.RepositoryID != taskRepository.RepositoryID { + continue + } + if normalizeTaskPRBranch(pr.HeadBranch) != checkoutBranch { + continue + } + matches = append(matches, taskPRMatch{ + taskRepositoryID: taskRepository.ID, + pr: pr, + }) + } + } + return matches +} + +func normalizeTaskPRBranch(branch string) string { + branch = strings.TrimSpace(branch) + for _, prefix := range []string{ + "refs/heads/", + "refs/remotes/origin/", + "origin/", + } { + if strings.HasPrefix(branch, prefix) { + return strings.TrimSpace(strings.TrimPrefix(branch, prefix)) + } + } + return branch +} + +func shouldSkipForTerminalTaskPRs(prs []*github.TaskPR) bool { + if len(prs) == 0 { + return false + } + for _, pr := range prs { + if pr == nil { + return false + } + switch strings.ToLower(strings.TrimSpace(pr.State)) { + case githubPRStateClosed, githubPRStateMerged: + continue + default: + return false + } + } + return true +} + +func matchesAreTerminal(matches []taskPRMatch) bool { + if len(matches) == 0 { + return false + } + prs := make([]*github.TaskPR, 0, len(matches)) + for _, match := range matches { + prs = append(prs, match.pr) + } + return shouldSkipForTerminalTaskPRs(prs) +} + +func taskPRLaunchErrorStamp(matches []taskPRMatch) string { + keys := make([]string, 0, len(matches)) + for _, match := range matches { + if match.pr == nil { + continue + } + keys = append(keys, strings.Join([]string{ + match.taskRepositoryID, + match.pr.RepositoryID, + strconv.Itoa(match.pr.PRNumber), + strings.ToLower(strings.TrimSpace(match.pr.State)), + }, "\x00")) + } + if len(keys) == 0 { + return "" + } + sort.Strings(keys) + parts := append([]string{"pr_already_closed"}, keys...) + return models.StableLaunchErrorStamp(parts...) +} + +func taskPRLaunchErrorRepositoryID(matches []taskPRMatch) string { + var repositoryID string + for _, match := range matches { + if strings.TrimSpace(match.taskRepositoryID) == "" { + return "" + } + if repositoryID == "" { + repositoryID = match.taskRepositoryID + continue + } + if repositoryID != match.taskRepositoryID { + return "" + } + } + return repositoryID +} + +func workflowHasValidTerminalFinalStep( + ctx context.Context, + getter WorkflowStepGetter, + stepID string, +) bool { + finalStep, err := findFinalWorkflowStep(ctx, getter, stepID) + return err == nil && finalStep != nil +} + +// shouldSkipTerminalPRAutoStart is the pre-session PR gate. Lookup failures +// deliberately return false so a stale integration cannot suppress work. +func (s *Service) shouldSkipTerminalPRAutoStart( + ctx context.Context, + task *models.Task, +) bool { + if task == nil || task.ID == "" || s.githubService == nil { + return false + } + store, ok := s.repo.(interface { + ListTaskRepositories(context.Context, string) ([]*models.TaskRepository, error) + }) + if !ok { + return false + } + taskRepositories, err := store.ListTaskRepositories(ctx, task.ID) + if err != nil { + s.logger.Debug("failed to load task repositories for PR auto-start gate", + zap.String("task_id", task.ID), zap.Error(err)) + return false + } + lookupCtx, cancel := context.WithTimeout(ctx, launchFailurePRLookupTimeout) + defer cancel() + prsByTask, err := s.githubService.ListTaskPRs(lookupCtx, []string{task.ID}) + if err != nil { + s.logger.Debug("failed to load task PRs for auto-start gate", + zap.String("task_id", task.ID), zap.Error(err)) + return false + } + matches := selectRelevantTaskPRs(taskRepositories, prsByTask[task.ID]) + if !matchesAreTerminal(matches) { + return false + } + + errorValue := models.TaskLaunchError{ + Message: terminalPRLaunchMessage, + OccurredAt: time.Now().UTC(), + Code: models.LaunchErrorCategoryPRAlreadyClosed, + RecoveryActions: terminalPRRecoveryActions(ctx, s.workflowStepGetter, task.WorkflowStepID), + TaskRepositoryID: taskPRLaunchErrorRepositoryID(matches), + StampValue: taskPRLaunchErrorStamp(matches), + } + return s.persistTaskLaunchError(ctx, task.ID, errorValue) +} + +// resolveLaunchFailureReviewEligibility is deliberately narrow. It is used by +// the executor after a session launch fails, so lookup failures are returned to +// the caller and only suppress the optional action. +func (s *Service) resolveLaunchFailureReviewEligibility( + ctx context.Context, + taskID string, +) (bool, error) { + if s.githubService == nil || s.workflowStepGetter == nil { + return false, nil + } + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + return false, err + } + if task == nil { + return false, fmt.Errorf("task %q was not found", taskID) + } + store, ok := s.repo.(interface { + ListTaskRepositories(context.Context, string) ([]*models.TaskRepository, error) + }) + if !ok { + return false, fmt.Errorf("task repository reader is unavailable") + } + taskRepositories, err := store.ListTaskRepositories(ctx, taskID) + if err != nil { + return false, err + } + prsByTask, err := s.githubService.ListTaskPRs(ctx, []string{taskID}) + if err != nil { + return false, err + } + matches := selectRelevantTaskPRs(taskRepositories, prsByTask[taskID]) + return matchesAreTerminal(matches) && workflowHasValidTerminalFinalStep( + ctx, s.workflowStepGetter, task.WorkflowStepID, + ), nil +} + +func terminalPRRecoveryActions( + ctx context.Context, + getter WorkflowStepGetter, + workflowStepID string, +) []string { + if !workflowHasValidTerminalFinalStep(ctx, getter, workflowStepID) { + return nil + } + return []string{models.RecoveryActionMarkReviewDone} +} + +func (s *Service) persistTaskLaunchError( + ctx context.Context, + taskID string, + errorValue models.TaskLaunchError, +) bool { + persister, ok := s.repo.(taskLaunchErrorPersister) + if !ok { + s.logger.Warn("task repository cannot persist PR auto-start error", + zap.String("task_id", taskID)) + return false + } + stored, noOp, err := persister.SetTaskMetadataKeyIfDifferentStamp( + ctx, taskID, models.MetaKeyLastLaunchError, errorValue.Stamp(), errorValue, + ) + if err != nil { + s.logger.Warn("failed to persist PR auto-start error", + zap.String("task_id", taskID), zap.Error(err)) + return false + } + if stored { + s.publishTaskLaunchErrorUpdate(ctx, taskID) + } + return stored || noOp +} + +type taskLaunchErrorPersister interface { + SetTaskMetadataKeyIfDifferentStamp( + context.Context, string, string, string, interface{}, + ) (stored bool, noOp bool, err error) +} + +func (s *Service) publishTaskLaunchErrorUpdate(ctx context.Context, taskID string) { + task, err := s.repo.GetTask(ctx, taskID) + if err != nil || task == nil { + if err != nil { + s.logger.Debug("failed to reload task after launch-error metadata update", + zap.String("task_id", taskID), zap.Error(err)) + } + return + } + s.publishTaskUpdated(ctx, task) +} + +type taskLaunchErrorStampRemover interface { + RemoveTaskMetadataKeyIfStamp( + ctx context.Context, + taskID, key, expectedStamp string, + ) (bool, error) +} + +// clearTaskLaunchErrorIfStamp removes only the error observed by the launch +// that just succeeded. The repository implementation performs the comparison +// and removal in one SQL statement; the fallback keeps test doubles useful. +func (s *Service) clearTaskLaunchErrorIfStamp( + ctx context.Context, + taskID, expectedStamp string, +) bool { + if strings.TrimSpace(taskID) == "" || strings.TrimSpace(expectedStamp) == "" { + return false + } + if remover, ok := s.repo.(taskLaunchErrorStampRemover); ok { + removed, err := remover.RemoveTaskMetadataKeyIfStamp( + ctx, taskID, models.MetaKeyLastLaunchError, expectedStamp, + ) + if err != nil { + s.logger.Warn("failed to clear task launch error", + zap.String("task_id", taskID), zap.Error(err)) + } + if removed { + s.publishTaskLaunchErrorUpdate(ctx, taskID) + } + return removed + } + + task, err := s.repo.GetTask(ctx, taskID) + if err != nil || task == nil { + return false + } + current, found := models.LoadTaskLaunchError(task.Metadata) + if !found || !current.MatchesStamp(expectedStamp) { + return false + } + if remover, ok := s.repo.(taskMetadataKeyRemover); ok { + removed, err := remover.RemoveTaskMetadataKey(ctx, taskID, models.MetaKeyLastLaunchError) + if err != nil { + s.logger.Warn("failed to clear task launch error", + zap.String("task_id", taskID), zap.Error(err)) + return false + } + if removed { + s.publishTaskLaunchErrorUpdate(ctx, taskID) + } + return removed + } + return false +} diff --git a/apps/backend/internal/orchestrator/task_launch_pr_gate_test.go b/apps/backend/internal/orchestrator/task_launch_pr_gate_test.go new file mode 100644 index 0000000000..304df8e7c9 --- /dev/null +++ b/apps/backend/internal/orchestrator/task_launch_pr_gate_test.go @@ -0,0 +1,302 @@ +package orchestrator + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" +) + +type failTaskLaunchErrorPersistRepo struct { + repoStore + err error +} + +func (r *failTaskLaunchErrorPersistRepo) SetTaskMetadataKeyIfDifferentStamp(context.Context, string, string, string, interface{}) (bool, bool, error) { + return false, false, r.err +} + +func TestSelectRelevantTaskPRsUsesExactTaskRepositoryIdentity(t *testing.T) { + taskRepo := &models.TaskRepository{ + ID: "task-repo-1", + RepositoryID: "repo-1", + CheckoutBranch: "feature/current", + Metadata: map[string]interface{}{"pr_number": float64(42)}, + } + prs := []*github.TaskPR{ + {ID: "wrong-repo", RepositoryID: "repo-2", PRNumber: 42, State: githubPRStateClosed}, + {ID: "wrong-number", RepositoryID: "repo-1", PRNumber: 41, HeadBranch: "feature/current", State: githubPRStateClosed}, + {ID: "match", RepositoryID: "repo-1", PRNumber: 42, HeadBranch: "other", State: githubPRStateClosed}, + } + + matches := selectRelevantTaskPRs([]*models.TaskRepository{taskRepo}, prs) + if len(matches) != 1 || matches[0].pr.ID != "match" { + t.Fatalf("selectRelevantTaskPRs() = %#v, want only exact repository/PR match", matches) + } +} + +func TestSelectRelevantTaskPRsFallsBackToNormalizedCaseSensitiveBranches(t *testing.T) { + taskRepo := &models.TaskRepository{ + ID: "task-repo-1", + RepositoryID: "repo-1", + CheckoutBranch: " refs/remotes/origin/feature/current ", + } + prs := []*github.TaskPR{ + {ID: "match", RepositoryID: "repo-1", HeadBranch: "origin/feature/current", State: githubPRStateMerged}, + {ID: "case-mismatch", RepositoryID: "repo-1", HeadBranch: "feature/Current", State: githubPRStateMerged}, + {ID: "prefix-mismatch", RepositoryID: "repo-1", HeadBranch: "refs/heads/refs/remotes/origin/feature/current", State: githubPRStateMerged}, + } + + matches := selectRelevantTaskPRs([]*models.TaskRepository{taskRepo}, prs) + if len(matches) != 1 || matches[0].pr.ID != "match" { + t.Fatalf("selectRelevantTaskPRs() = %#v, want one normalized branch match", matches) + } +} + +func TestTerminalTaskPRGateRequiresAllRelevantPRsTerminal(t *testing.T) { + tests := []struct { + name string + prs []*github.TaskPR + want bool + }{ + { + name: "merged and closed", + prs: []*github.TaskPR{ + {RepositoryID: "repo-1", PRNumber: 1, State: githubPRStateMerged}, + {RepositoryID: "repo-1", PRNumber: 2, State: githubPRStateClosed}, + }, + want: true, + }, + {name: "open wins", prs: []*github.TaskPR{{State: githubPRStateClosed}, {State: githubPRStateOpen}}, want: false}, + {name: "empty state fails open", prs: []*github.TaskPR{{State: githubPRStateClosed}, {State: ""}}, want: false}, + {name: "unknown state fails open", prs: []*github.TaskPR{{State: githubPRStateClosed}, {State: "draft"}}, want: false}, + {name: "absent matches fails open", prs: nil, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldSkipForTerminalTaskPRs(tt.prs); got != tt.want { + t.Fatalf("shouldSkipForTerminalTaskPRs() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTerminalTaskPRGateDoesNotUseSiblingPR(t *testing.T) { + taskRepos := []*models.TaskRepository{ + {ID: "current", RepositoryID: "repo-1", CheckoutBranch: "feature/current"}, + {ID: "sibling", RepositoryID: "repo-1", CheckoutBranch: "feature/sibling"}, + } + prs := []*github.TaskPR{ + {ID: "current-pr", RepositoryID: "repo-1", HeadBranch: "feature/current", State: githubPRStateOpen}, + {ID: "sibling-pr", RepositoryID: "repo-1", HeadBranch: "feature/sibling", State: githubPRStateMerged}, + } + + matches := selectRelevantTaskPRs(taskRepos, prs) + if len(matches) != 2 { + t.Fatalf("selectRelevantTaskPRs() returned %d matches, want current and sibling", len(matches)) + } + if shouldSkipForTerminalTaskPRs(matchesToPRs(matches)) { + t.Fatal("an open current branch must prevent the terminal sibling from gating launch") + } +} + +func TestTaskPRLaunchErrorStampIsStableAndIncludesState(t *testing.T) { + matches := []taskPRMatch{ + {taskRepositoryID: "repo-row", pr: &github.TaskPR{RepositoryID: "repo-1", PRNumber: 42, State: githubPRStateMerged}}, + } + first := taskPRLaunchErrorStamp(matches) + second := taskPRLaunchErrorStamp([]taskPRMatch{{ + taskRepositoryID: "repo-row", + pr: &github.TaskPR{RepositoryID: "repo-1", PRNumber: 42, State: " merged ", HeadBranch: "different"}, + }}) + if first == "" || first != second { + t.Fatalf("stamps differ for equivalent PR identity/state: %q vs %q", first, second) + } + changed := taskPRLaunchErrorStamp([]taskPRMatch{{ + taskRepositoryID: "repo-row", + pr: &github.TaskPR{RepositoryID: "repo-1", PRNumber: 42, State: githubPRStateClosed}, + }}) + if first == changed { + t.Fatal("PR state must be part of the launch-error stamp") + } +} + +func TestWorkflowHasValidTerminalFinalStep(t *testing.T) { + getter := newMockStepGetter() + getter.steps["review"] = &wfmodels.WorkflowStep{ID: "review", WorkflowID: "workflow", Position: 1, Name: "Review"} + getter.steps["done"] = &wfmodels.WorkflowStep{ID: "done", WorkflowID: "workflow", Position: 2, Name: "Done"} + + if !workflowHasValidTerminalFinalStep(t.Context(), getter, "review") { + t.Fatal("final Done step should enable mark_review_done") + } + + getter.steps["done"].Name = "Archive" + if workflowHasValidTerminalFinalStep(t.Context(), getter, "review") { + t.Fatal("non-terminal final step should not enable mark_review_done") + } +} + +func TestShouldSkipTerminalPRAutoStartPersistsAndClearsStampedError(t *testing.T) { + ctx := t.Context() + repo := setupTestRepo(t) + seedSession(t, repo, "task-pr-gate", "session-pr-gate", "review") + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-1", WorkspaceID: "ws1", Name: "repo", SourceType: "local", + }); err != nil { + t.Fatalf("create repository: %v", err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "task-repo-pr-gate", TaskID: "task-pr-gate", RepositoryID: "repo-1", + CheckoutBranch: "feature/current", Metadata: map[string]interface{}{"pr_number": float64(42)}, + }); err != nil { + t.Fatalf("create task repository: %v", err) + } + steps := newMockStepGetter() + steps.steps["review"] = &wfmodels.WorkflowStep{ID: "review", WorkflowID: "wf1", Position: 1, Name: "Review"} + steps.steps["done"] = &wfmodels.WorkflowStep{ID: "done", WorkflowID: "wf1", Position: 2, Name: "Done"} + svc := createTestService(repo, steps, newMockTaskRepo()) + svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{ + {TaskID: "task-pr-gate", RepositoryID: "repo-1", PRNumber: 42, State: githubPRStateMerged}, + }}) + + task, err := repo.GetTask(ctx, "task-pr-gate") + if err != nil { + t.Fatalf("get task: %v", err) + } + if !svc.shouldSkipTerminalPRAutoStart(ctx, task) { + t.Fatal("terminal relevant PR should suppress automated launch") + } + stored, err := repo.GetTask(ctx, "task-pr-gate") + if err != nil { + t.Fatalf("reload task: %v", err) + } + errorValue, found := models.LoadTaskLaunchError(stored.Metadata) + if !found || errorValue.Code != models.LaunchErrorCategoryPRAlreadyClosed { + t.Fatalf("stored launch error = %#v, found=%v", errorValue, found) + } + if len(errorValue.RecoveryActions) != 1 || errorValue.RecoveryActions[0] != models.RecoveryActionMarkReviewDone { + t.Fatalf("recovery actions = %#v, want mark_review_done", errorValue.RecoveryActions) + } + + if !svc.shouldSkipTerminalPRAutoStart(ctx, stored) { + t.Fatal("replaying the same terminal PR gate should still suppress launch") + } + if _, err := repo.GetTask(ctx, "task-pr-gate"); err != nil { + t.Fatalf("reload task after replay: %v", err) + } + svc.clearTaskLaunchErrorIfStamp(ctx, "task-pr-gate", "stale-stamp") + stillPresent, err := repo.GetTask(ctx, "task-pr-gate") + if err != nil { + t.Fatalf("reload after stale clear: %v", err) + } + if _, found := models.LoadTaskLaunchError(stillPresent.Metadata); !found { + t.Fatal("stale clear removed the current launch error") + } + svc.clearTaskLaunchErrorIfStamp(ctx, "task-pr-gate", errorValue.Stamp()) + cleared, err := repo.GetTask(ctx, "task-pr-gate") + if err != nil { + t.Fatalf("reload after current clear: %v", err) + } + if _, found := models.LoadTaskLaunchError(cleared.Metadata); found { + t.Fatal("current stamped launch error was not cleared") + } +} + +func TestShouldSkipTerminalPRAutoStartFailsOpenWhenErrorPersistenceFails(t *testing.T) { + ctx := t.Context() + repo := setupTestRepo(t) + seedSession(t, repo, "task-pr-gate-persist-failure", "session-pr-gate-persist-failure", "review") + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-pr-gate-persist-failure", WorkspaceID: "ws1", Name: "repo", SourceType: "local", + }); err != nil { + t.Fatalf("create repository: %v", err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "task-repo-pr-gate-persist-failure", TaskID: "task-pr-gate-persist-failure", + RepositoryID: "repo-pr-gate-persist-failure", CheckoutBranch: "feature/current", + Metadata: map[string]interface{}{"pr_number": float64(42)}, + }); err != nil { + t.Fatalf("create task repository: %v", err) + } + steps := newMockStepGetter() + steps.steps["review"] = &wfmodels.WorkflowStep{ID: "review", WorkflowID: "wf1", Position: 1, Name: "Review"} + steps.steps["done"] = &wfmodels.WorkflowStep{ID: "done", WorkflowID: "wf1", Position: 2, Name: "Done"} + svc := createTestService(repo, steps, newMockTaskRepo()) + svc.repo = &failTaskLaunchErrorPersistRepo{repoStore: repo, err: errors.New("metadata unavailable")} + svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{ + {TaskID: "task-pr-gate-persist-failure", RepositoryID: "repo-pr-gate-persist-failure", PRNumber: 42, State: githubPRStateMerged}, + }}) + + task, err := repo.GetTask(ctx, "task-pr-gate-persist-failure") + if err != nil { + t.Fatalf("get task: %v", err) + } + if svc.shouldSkipTerminalPRAutoStart(ctx, task) { + t.Fatal("PR gate suppressed launch after durable error persistence failed") + } +} + +func TestPersistTaskLaunchErrorConcurrentSameStampPreservesOneOccurrence(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "task-pr-gate-concurrent", "session-pr-gate-concurrent", "review") + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + ctx := context.Background() + first := models.TaskLaunchError{ + Message: "terminal PR", OccurredAt: time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC), + Code: models.LaunchErrorCategoryPRAlreadyClosed, StampValue: "same-pr-stamp", + } + second := models.TaskLaunchError{ + Message: "terminal PR", OccurredAt: time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC), + Code: models.LaunchErrorCategoryPRAlreadyClosed, StampValue: "same-pr-stamp", + } + start := make(chan struct{}) + results := make(chan bool, 2) + var wg sync.WaitGroup + for _, value := range []models.TaskLaunchError{first, second} { + value := value + wg.Add(1) + go func() { + defer wg.Done() + <-start + results <- svc.persistTaskLaunchError(ctx, "task-pr-gate-concurrent", value) + }() + } + close(start) + wg.Wait() + close(results) + var storedCount int + for stored := range results { + if stored { + storedCount++ + } + } + if storedCount != 2 { + t.Fatalf("successful persistence calls = %d, want store plus confirmed no-op", storedCount) + } + + task, err := repo.GetTask(ctx, "task-pr-gate-concurrent") + if err != nil { + t.Fatalf("get task: %v", err) + } + launchError, ok := models.LoadTaskLaunchError(task.Metadata) + if !ok || launchError.Stamp() != "same-pr-stamp" { + t.Fatalf("stored launch error = %#v, want same-pr-stamp", launchError) + } + if !launchError.OccurredAt.Equal(first.OccurredAt) && !launchError.OccurredAt.Equal(second.OccurredAt) { + t.Fatalf("stored occurrence = %s, want one of the concurrent first occurrences", launchError.OccurredAt) + } +} + +func matchesToPRs(matches []taskPRMatch) []*github.TaskPR { + prs := make([]*github.TaskPR, 0, len(matches)) + for _, match := range matches { + prs = append(prs, match.pr) + } + return prs +} diff --git a/apps/backend/internal/orchestrator/task_launch_recovery.go b/apps/backend/internal/orchestrator/task_launch_recovery.go new file mode 100644 index 0000000000..f9f0fd50a6 --- /dev/null +++ b/apps/backend/internal/orchestrator/task_launch_recovery.go @@ -0,0 +1,628 @@ +package orchestrator + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/runtime/routingerr" + "github.com/kandev/kandev/internal/task/models" + taskservice "github.com/kandev/kandev/internal/task/service" + "github.com/kandev/kandev/internal/task/statussummary" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + "github.com/kandev/kandev/internal/worktree" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +var ( + ErrTaskLaunchRecoveryStale = errors.New("task launch recovery request is stale") + ErrTaskLaunchRecoveryInvalid = errors.New("invalid task launch recovery request") +) + +const ( + taskLaunchRecoveryRetryDefault = models.RecoveryActionRetryDefault + taskLaunchRecoveryPickBranch = models.RecoveryActionPickBaseBranch + taskLaunchRecoveryMarkDone = models.RecoveryActionMarkReviewDone +) + +// TaskLaunchRecoveryRequest is the server-side shape of task.launch.recover. +// The WebSocket handler decodes the same fields without adding transport-only +// defaults. +type TaskLaunchRecoveryRequest struct { + TaskID string `json:"task_id"` + SessionID string `json:"session_id,omitempty"` + TaskRepositoryID string `json:"task_repository_id,omitempty"` + Action string `json:"action"` + BaseBranch string `json:"base_branch,omitempty"` + ErrorStamp string `json:"error_stamp"` +} + +// TaskLaunchRecoveryResponse is the success shape of task.launch.recover. +type TaskLaunchRecoveryResponse struct { + OK bool `json:"ok"` + TaskID string `json:"task_id"` + SessionID string `json:"session_id,omitempty"` +} + +type taskLaunchRecoveryWorktree interface { + ResolveRemoteDefaultBranch(context.Context, string) (string, error) +} + +type taskLaunchRecoveryTaskService interface { + UpdateRepositoryBaseBranch(context.Context, taskservice.UpdateRepositoryBaseBranchRequest) (*models.TaskRepository, error) + ListBranches(context.Context, string, string) ([]taskservice.Branch, error) + MoveTaskWithOptions(context.Context, string, string, string, int, taskservice.MoveTaskOptions) (*taskservice.MoveTaskResult, error) +} + +type taskLaunchRecoveryRepository interface { + ListTaskRepositories(context.Context, string) ([]*models.TaskRepository, error) + GetRepository(context.Context, string) (*models.Repository, error) + UpdateRepositoryDefaultBranch(context.Context, string, string, string) error + SetSessionMetadataKeyIfStamp(context.Context, string, string, string, interface{}) (bool, error) + SetTaskMetadataKeyIfStamp(context.Context, string, string, string, interface{}) (bool, error) + RemoveSessionMetadataKeyIfStamp(context.Context, string, string, string) (bool, error) +} + +type taskLaunchRecoverySource struct { + task *models.Task + session *models.TaskSession + taskError models.TaskLaunchError + sessionError models.LastAgentError + sessionOwned bool + errorStamp string +} + +// SetTaskLaunchRecoveryService wires the task-service operations used by the +// task-scoped recovery action. The narrow interface keeps isolated +// orchestrator tests independent from task-service construction. +func (s *Service) SetTaskLaunchRecoveryService(svc taskLaunchRecoveryTaskService) { + s.taskLaunchRecoveryTasks = svc +} + +// RecoverTaskLaunch validates and executes one task-scoped launch recovery +// action. All reads happen after task authorization, and source-error clearing +// is compare-and-clear so an overlapping failure remains visible. +func (s *Service) RecoverTaskLaunch(ctx context.Context, req *TaskLaunchRecoveryRequest) (*TaskLaunchRecoveryResponse, error) { + if req == nil { + return nil, ErrTaskLaunchRecoveryInvalid + } + if err := s.validateTaskLaunchRecoveryRequest(req); err != nil { + return nil, err + } + if err := s.authorizeTask(ctx, req.TaskID); err != nil { + return nil, err + } + + source, err := s.loadTaskLaunchRecoverySource(ctx, req) + if err != nil { + return nil, err + } + if err := s.verifyCurrentTaskLaunchRecoverySource(ctx, source); err != nil { + return nil, err + } + if !containsRecoveryAction(source.recoveryActions(), req.Action) { + return nil, fmt.Errorf("recovery action %q is not available for the current error", req.Action) + } + + var responseSessionID string + switch req.Action { + case taskLaunchRecoveryRetryDefault, taskLaunchRecoveryPickBranch: + if err := s.recoverTaskLaunchBranch(ctx, req, source); err != nil { + return nil, err + } + responseSessionID, err = s.relaunchAndClearTaskLaunchRecovery(ctx, req, source) + if err != nil { + return nil, err + } + case taskLaunchRecoveryMarkDone: + if err := s.markTaskLaunchReviewDone(ctx, req, source); err != nil { + return nil, err + } + responseSessionID = req.SessionID + if err := s.clearTaskLaunchRecoverySource(ctx, source); err != nil { + return nil, err + } + default: + return nil, ErrTaskLaunchRecoveryInvalid + } + + return &TaskLaunchRecoveryResponse{OK: true, TaskID: req.TaskID, SessionID: responseSessionID}, nil +} + +func (s *Service) relaunchAndClearTaskLaunchRecovery( + ctx context.Context, + req *TaskLaunchRecoveryRequest, + source *taskLaunchRecoverySource, +) (string, error) { + responseSessionID, err := s.relaunchRecoveredTask(ctx, req, source) + if err != nil { + return "", err + } + if strings.TrimSpace(responseSessionID) == "" { + return "", fmt.Errorf("task launch recovery relaunch did not create a session") + } + if err := s.clearTaskLaunchRecoverySource(ctx, source); err != nil && s.logger != nil { + s.logger.Warn("task launch recovery relaunched successfully but could not clear the source error", + zap.String("task_id", req.TaskID), + zap.String("session_id", responseSessionID), + zap.Error(err)) + } + return responseSessionID, nil +} + +func (s *Service) validateTaskLaunchRecoveryRequest(req *TaskLaunchRecoveryRequest) error { + if strings.TrimSpace(req.TaskID) == "" || strings.TrimSpace(req.Action) == "" || strings.TrimSpace(req.ErrorStamp) == "" { + return ErrTaskLaunchRecoveryInvalid + } + switch req.Action { + case taskLaunchRecoveryRetryDefault, taskLaunchRecoveryPickBranch, taskLaunchRecoveryMarkDone: + default: + return fmt.Errorf("unsupported task launch recovery action %q", req.Action) + } + if req.Action != taskLaunchRecoveryMarkDone && strings.TrimSpace(req.TaskRepositoryID) == "" { + return fmt.Errorf("task_repository_id is required for %s", req.Action) + } + if req.Action == taskLaunchRecoveryPickBranch && strings.TrimSpace(req.BaseBranch) == "" { + return fmt.Errorf("base_branch is required for %s", req.Action) + } + return nil +} + +func (s *Service) loadTaskLaunchRecoverySource(ctx context.Context, req *TaskLaunchRecoveryRequest) (*taskLaunchRecoverySource, error) { + task, err := s.repo.GetTask(ctx, req.TaskID) + if err != nil { + return nil, err + } + if task == nil { + return nil, fmt.Errorf("task %q was not found", req.TaskID) + } + var source *taskLaunchRecoverySource + if req.SessionID != "" { + source, err = s.loadSessionTaskLaunchRecoverySource(ctx, req, task) + } else { + source, err = loadTaskMetadataLaunchRecoverySource(req, task) + } + if err != nil { + return nil, err + } + + if req.TaskRepositoryID != "" { + if _, err := s.taskRepositoryForRecovery(ctx, req.TaskID, req.TaskRepositoryID); err != nil { + return nil, err + } + } + if req.Action != taskLaunchRecoveryMarkDone { + activeRepositoryID := source.taskRepositoryID() + if activeRepositoryID == "" || activeRepositoryID != req.TaskRepositoryID { + return nil, fmt.Errorf("task_repository_id does not match the active launch error") + } + } + return source, nil +} + +func (s *Service) verifyCurrentTaskLaunchRecoverySource( + ctx context.Context, + source *taskLaunchRecoverySource, +) error { + current, err := s.loadAuthoritativeTaskLaunchRecoveryError(ctx, source.task.ID) + if err != nil { + return err + } + if current == nil || current.SessionID != source.sessionID() || current.Stamp != source.errorStamp { + return ErrTaskLaunchRecoveryStale + } + return nil +} + +func (s *Service) loadAuthoritativeTaskLaunchRecoveryError( + ctx context.Context, + taskID string, +) (*statussummary.ActiveErrorSummary, error) { + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + return nil, err + } + if task == nil { + return nil, fmt.Errorf("task %q was not found", taskID) + } + sessions, err := s.repo.ListTaskSessions(ctx, taskID) + if err != nil { + return nil, err + } + input := statussummary.RebuildInput{Now: time.Now().UTC()} + for _, session := range sessions { + if session == nil { + continue + } + lastError, found := models.LoadLastAgentError(session.Metadata) + if !found || lastError.IsDismissed() { + continue + } + input.Sessions = append(input.Sessions, statussummary.RebuildSession{ + ID: session.ID, + ActiveError: &statussummary.ActiveErrorSummary{ + SessionID: session.ID, + TaskRepositoryID: lastError.TaskRepositoryID, + Stamp: lastError.Stamp(), + OccurredAt: lastError.OccurredAt, + Preview: lastError.Message, + Category: lastError.Code, + RecoveryActions: lastError.RecoveryActions, + }, + }) + } + if launchError, found := models.LoadTaskLaunchError(task.Metadata); found { + input.TaskError = &statussummary.ActiveErrorSummary{ + TaskRepositoryID: launchError.TaskRepositoryID, + Stamp: launchError.Stamp(), + OccurredAt: launchError.OccurredAt, + Preview: launchError.Message, + Category: launchError.Code, + RecoveryActions: launchError.RecoveryActions, + } + } + return statussummary.BuildFromAuthoritative(input).ActiveError, nil +} + +func (s *Service) loadSessionTaskLaunchRecoverySource( + ctx context.Context, + req *TaskLaunchRecoveryRequest, + task *models.Task, +) (*taskLaunchRecoverySource, error) { + if err := s.authorizeSession(ctx, req.SessionID); err != nil { + return nil, err + } + session, err := s.repo.GetTaskSession(ctx, req.SessionID) + if err != nil { + return nil, err + } + if session == nil || session.TaskID != req.TaskID { + return nil, fmt.Errorf("session does not belong to task") + } + lastError, found := models.LoadLastAgentError(session.Metadata) + if !found { + return nil, fmt.Errorf("session has no active launch error") + } + if !lastError.MatchesStamp(req.ErrorStamp) { + return nil, ErrTaskLaunchRecoveryStale + } + return &taskLaunchRecoverySource{ + task: task, + session: session, + sessionError: lastError, + sessionOwned: true, + errorStamp: lastError.Stamp(), + }, nil +} + +func loadTaskMetadataLaunchRecoverySource( + req *TaskLaunchRecoveryRequest, + task *models.Task, +) (*taskLaunchRecoverySource, error) { + launchError, found := models.LoadTaskLaunchError(task.Metadata) + if !found { + return nil, fmt.Errorf("task has no active launch error") + } + if !launchError.MatchesStamp(req.ErrorStamp) { + return nil, ErrTaskLaunchRecoveryStale + } + return &taskLaunchRecoverySource{ + task: task, + taskError: launchError, + errorStamp: launchError.Stamp(), + }, nil +} + +func (s *Service) taskRepositoryForRecovery(ctx context.Context, taskID, taskRepositoryID string) (*models.TaskRepository, error) { + if s.taskLaunchRecoveryRepo == nil { + return nil, fmt.Errorf("task launch recovery repository is unavailable") + } + rows, err := s.taskLaunchRecoveryRepo.ListTaskRepositories(ctx, taskID) + if err != nil { + return nil, err + } + for _, row := range rows { + if row != nil && row.ID == taskRepositoryID { + return row, nil + } + } + return nil, fmt.Errorf("task repository does not belong to task") +} + +func (source *taskLaunchRecoverySource) taskRepositoryID() string { + if source.sessionOwned { + return source.sessionError.TaskRepositoryID + } + return source.taskError.TaskRepositoryID +} + +func (source *taskLaunchRecoverySource) sessionID() string { + if source.sessionOwned && source.session != nil { + return source.session.ID + } + return "" +} + +func (source *taskLaunchRecoverySource) recoveryActions() []string { + if source.sessionOwned { + return source.sessionError.RecoveryActions + } + return source.taskError.RecoveryActions +} + +func containsRecoveryAction(actions []string, wanted string) bool { + for _, action := range actions { + if action == wanted { + return true + } + } + return false +} + +func (s *Service) recoverTaskLaunchBranch(ctx context.Context, req *TaskLaunchRecoveryRequest, source *taskLaunchRecoverySource) error { + if s.taskLaunchRecoveryTasks == nil { + return fmt.Errorf("task launch recovery service is unavailable") + } + taskRepository, err := s.taskRepositoryForRecovery(ctx, req.TaskID, req.TaskRepositoryID) + if err != nil { + return err + } + if s.taskLaunchRecoveryRepo == nil { + return fmt.Errorf("task launch recovery repository is unavailable") + } + repository, err := s.taskLaunchRecoveryRepo.GetRepository(ctx, taskRepository.RepositoryID) + if err != nil { + return err + } + if repository == nil { + return fmt.Errorf("repository does not exist") + } + + baseBranch, err := s.resolveTaskLaunchRecoveryBranch(ctx, req, repository, source, taskRepository.ID) + if err != nil { + return err + } + + if req.Action == taskLaunchRecoveryRetryDefault && repository.DefaultBranch != baseBranch { + if err := s.taskLaunchRecoveryRepo.UpdateRepositoryDefaultBranch(ctx, repository.ID, repository.DefaultBranch, baseBranch); err != nil { + return fmt.Errorf("update repository default branch: %w", err) + } + } + updatedTaskRepository, err := s.taskLaunchRecoveryTasks.UpdateRepositoryBaseBranch(ctx, taskservice.UpdateRepositoryBaseBranchRequest{ + TaskID: req.TaskID, + TaskRepositoryID: taskRepository.ID, + BaseBranch: baseBranch, + }) + if err != nil { + return fmt.Errorf("update task repository base branch: %w", err) + } + s.logger.Debug("task launch recovery updated task repository base branch", + zap.String("task_id", req.TaskID), + zap.String("task_repository_id", taskRepository.ID), + zap.String("requested_base_branch", baseBranch), + zap.String("stored_base_branch", updatedTaskRepository.BaseBranch), + ) + return nil +} + +func (s *Service) resolveTaskLaunchRecoveryBranch( + ctx context.Context, + req *TaskLaunchRecoveryRequest, + repository *models.Repository, + source *taskLaunchRecoverySource, + taskRepositoryID string, +) (string, error) { + baseBranch := strings.TrimSpace(req.BaseBranch) + if req.Action != taskLaunchRecoveryRetryDefault { + if err := s.validateSelectedRecoveryBranch(ctx, repository, baseBranch); err != nil { + return "", err + } + return baseBranch, nil + } + if s.taskLaunchRecoveryWorktree == nil { + return "", fmt.Errorf("live default-branch resolver is unavailable") + } + baseBranch, err := s.taskLaunchRecoveryWorktree.ResolveRemoteDefaultBranch(ctx, repository.LocalPath) + if err == nil && strings.TrimSpace(baseBranch) != "" { + return baseBranch, nil + } + if errors.Is(err, worktree.ErrRemoteDefaultUnresolved) || strings.TrimSpace(baseBranch) == "" { + return "", s.recordUnresolvedDefaultBranch(ctx, source, taskRepositoryID, err) + } + return "", err +} + +func (s *Service) validateSelectedRecoveryBranch(ctx context.Context, repository *models.Repository, branch string) error { + if s.taskLaunchRecoveryTasks == nil || repository == nil { + return fmt.Errorf("task launch recovery service is unavailable") + } + wanted := normalizeTaskPRBranch(branch) + if wanted == "" { + return worktree.ErrInvalidBaseBranch + } + branches, err := s.taskLaunchRecoveryTasks.ListBranches(ctx, repository.ID, repository.LocalPath) + if err != nil { + return fmt.Errorf("list remote branches: %w", err) + } + for _, candidate := range branches { + candidateType := strings.TrimSpace(candidate.Type) + if strings.EqualFold(candidateType, "remote") && normalizeTaskPRBranch(candidate.Name) == wanted { + return nil + } + } + return worktree.ErrInvalidBaseBranch +} + +func (s *Service) recordUnresolvedDefaultBranch(ctx context.Context, source *taskLaunchRecoverySource, taskRepositoryID string, cause error) error { + const message = "The remote default branch could not be resolved." + details := "" + if cause != nil { + details = routingerr.SanitizeError(cause).Error() + } + stamp := models.StableLaunchErrorStamp("default_branch_unresolved", taskRepositoryID, source.errorStamp) + if source.sessionOwned { + return s.recordSessionUnresolvedDefault(ctx, source, taskRepositoryID, details, stamp, message) + } + return s.recordTaskUnresolvedDefault(ctx, source, taskRepositoryID, details, stamp, message) +} + +func (s *Service) recordSessionUnresolvedDefault( + ctx context.Context, + source *taskLaunchRecoverySource, + taskRepositoryID, details, stamp, message string, +) error { + value := models.LastAgentError{ + Message: message, + OccurredAt: time.Now().UTC(), + Code: models.LaunchErrorCategoryDefaultBranchUnresolved, + Details: details, + RecoveryActions: []string{models.RecoveryActionPickBaseBranch}, + TaskRepositoryID: taskRepositoryID, + StampValue: stamp, + } + stored, err := s.taskLaunchRecoveryRepo.SetSessionMetadataKeyIfStamp( + ctx, + source.session.ID, + models.SessionMetaKeyLastAgentError, + source.errorStamp, + value, + ) + if err != nil { + return err + } + if !stored { + return ErrTaskLaunchRecoveryStale + } + if s.eventBus != nil { + if err := s.publishTaskSessionErrorEvent(ctx, source.task.ID, source.session.ID, true, &value); err != nil { + s.logger.Warn("failed to publish unresolved-default session error", + zap.String("task_id", source.task.ID), + zap.String("session_id", source.session.ID), + zap.Error(err)) + } + } + return fmt.Errorf("%w: choose a base branch", worktree.ErrRemoteDefaultUnresolved) +} + +func (s *Service) recordTaskUnresolvedDefault( + ctx context.Context, + source *taskLaunchRecoverySource, + taskRepositoryID, details, stamp, message string, +) error { + value := models.TaskLaunchError{ + Message: message, + OccurredAt: time.Now().UTC(), + Code: models.LaunchErrorCategoryDefaultBranchUnresolved, + Details: details, + RecoveryActions: []string{models.RecoveryActionPickBaseBranch}, + TaskRepositoryID: taskRepositoryID, + StampValue: stamp, + } + stored, err := s.taskLaunchRecoveryRepo.SetTaskMetadataKeyIfStamp( + ctx, + source.task.ID, + models.MetaKeyLastLaunchError, + source.errorStamp, + value, + ) + if err != nil { + return err + } + if !stored { + return ErrTaskLaunchRecoveryStale + } + s.publishTaskLaunchErrorUpdate(ctx, source.task.ID) + return fmt.Errorf("%w: choose a base branch", worktree.ErrRemoteDefaultUnresolved) +} + +func (s *Service) relaunchRecoveredTask(ctx context.Context, req *TaskLaunchRecoveryRequest, source *taskLaunchRecoverySource) (string, error) { + if source.sessionOwned { + response, err := s.RecoverSession(ctx, req.TaskID, source.session.ID, "fresh_start") + if err != nil { + return "", err + } + if response == nil { + return "", nil + } + return response.SessionID, nil + } + response, err := s.LaunchSession(ctx, &LaunchSessionRequest{ + TaskID: req.TaskID, + Intent: IntentStart, + WorkflowStepID: source.task.WorkflowStepID, + }) + if err != nil { + return "", err + } + if response == nil { + return "", nil + } + return response.SessionID, nil +} + +func (s *Service) markTaskLaunchReviewDone(ctx context.Context, req *TaskLaunchRecoveryRequest, source *taskLaunchRecoverySource) error { + if s.taskLaunchRecoveryTasks == nil || s.githubService == nil || s.workflowStepGetter == nil { + return fmt.Errorf("review completion recovery is unavailable") + } + finalStep, err := s.findTerminalFinalWorkflowStep(ctx, source.task) + if err != nil { + return err + } + if finalStep == nil { + return fmt.Errorf("workflow has no valid terminal final step") + } + if s.taskLaunchRecoveryRepo == nil { + return fmt.Errorf("task launch recovery repository is unavailable") + } + rows, err := s.taskLaunchRecoveryRepo.ListTaskRepositories(ctx, source.task.ID) + if err != nil { + return err + } + lookupCtx, cancel := context.WithTimeout(ctx, launchFailurePRLookupTimeout) + defer cancel() + prsByTask, err := s.githubService.ListTaskPRs(lookupCtx, []string{source.task.ID}) + if err != nil { + return fmt.Errorf("recheck task pull requests: %w", err) + } + if !matchesAreTerminal(selectRelevantTaskPRs(rows, prsByTask[source.task.ID])) { + return fmt.Errorf("relevant pull requests are not all terminal") + } + if source.task.WorkflowStepID == finalStep.ID && source.task.State != v1.TaskStateFailed { + return nil + } + _, err = s.taskLaunchRecoveryTasks.MoveTaskWithOptions(ctx, source.task.ID, source.task.WorkflowID, finalStep.ID, finalStep.Position, taskservice.MoveTaskOptions{ + AllowFailedToCompletedRecovery: true, + StepHistorySessionID: req.SessionID, + }) + return err +} + +func (s *Service) findTerminalFinalWorkflowStep(ctx context.Context, task *models.Task) (*wfmodels.WorkflowStep, error) { + if task == nil || strings.TrimSpace(task.WorkflowID) == "" || s.workflowStepGetter == nil { + return nil, nil + } + step, err := findFinalWorkflowStep(ctx, s.workflowStepGetter, task.WorkflowStepID) + if err != nil { + return nil, err + } + if step == nil || step.WorkflowID != task.WorkflowID { + return nil, nil + } + return step, nil +} + +func (s *Service) clearTaskLaunchRecoverySource(ctx context.Context, source *taskLaunchRecoverySource) error { + if source.sessionOwned { + if s.taskLaunchRecoveryRepo == nil { + return fmt.Errorf("session launch-error compare-and-clear is unavailable") + } + _, err := s.taskLaunchRecoveryRepo.RemoveSessionMetadataKeyIfStamp(ctx, source.session.ID, models.SessionMetaKeyLastAgentError, source.errorStamp) + return err + } + s.clearTaskLaunchErrorIfStamp(ctx, source.task.ID, source.errorStamp) + return nil +} diff --git a/apps/backend/internal/orchestrator/task_launch_recovery_test.go b/apps/backend/internal/orchestrator/task_launch_recovery_test.go new file mode 100644 index 0000000000..ee4107c997 --- /dev/null +++ b/apps/backend/internal/orchestrator/task_launch_recovery_test.go @@ -0,0 +1,420 @@ +package orchestrator + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" + taskservice "github.com/kandev/kandev/internal/task/service" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + "github.com/kandev/kandev/internal/worktree" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +type taskLaunchRecoveryServiceFake struct { + branches []taskservice.Branch + branchErr error + updated []taskservice.UpdateRepositoryBaseBranchRequest + moved []taskservice.MoveTaskOptions + moveErr error + moveTaskIDs []string +} + +func (f *taskLaunchRecoveryServiceFake) UpdateRepositoryBaseBranch(_ context.Context, req taskservice.UpdateRepositoryBaseBranchRequest) (*models.TaskRepository, error) { + f.updated = append(f.updated, req) + return &models.TaskRepository{ID: req.TaskRepositoryID, TaskID: req.TaskID, BaseBranch: req.BaseBranch}, nil +} + +func (f *taskLaunchRecoveryServiceFake) ListBranches(context.Context, string, string) ([]taskservice.Branch, error) { + return f.branches, f.branchErr +} + +func (f *taskLaunchRecoveryServiceFake) MoveTaskWithOptions(_ context.Context, taskID, workflowID, workflowStepID string, position int, opts taskservice.MoveTaskOptions) (*taskservice.MoveTaskResult, error) { + f.moveTaskIDs = append(f.moveTaskIDs, taskID) + f.moved = append(f.moved, opts) + if f.moveErr != nil { + return nil, f.moveErr + } + return &taskservice.MoveTaskResult{Task: &models.Task{ID: taskID, WorkflowID: workflowID, WorkflowStepID: workflowStepID, Position: position}}, nil +} + +type taskLaunchRecoveryWorktreeFake struct { + branch string + err error +} + +func (f taskLaunchRecoveryWorktreeFake) ResolveRemoteDefaultBranch(context.Context, string) (string, error) { + return f.branch, f.err +} + +type blockingTaskLaunchRecoveryWorktree struct { + started chan<- struct{} + release <-chan struct{} + err error +} + +func (f blockingTaskLaunchRecoveryWorktree) ResolveRemoteDefaultBranch(context.Context, string) (string, error) { + f.started <- struct{}{} + <-f.release + return "", f.err +} + +func seedTaskLaunchRecoveryFixture(t *testing.T, repo *sqliterepo.Repository, taskID, taskRepositoryID string, action string) { + t.Helper() + ctx := context.Background() + now := time.Now().UTC() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-recovery", Name: "Recovery", CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-recovery", WorkspaceID: "ws-recovery", Name: "Recovery workflow", CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatalf("CreateWorkflow: %v", err) + } + if err := repo.CreateTask(ctx, &models.Task{ + ID: taskID, WorkspaceID: "ws-recovery", WorkflowID: "wf-recovery", WorkflowStepID: "review-recovery", + Title: "Recovery task", State: v1.TaskStateFailed, Metadata: map[string]interface{}{ + models.MetaKeyLastLaunchError: models.TaskLaunchError{ + Message: "launch failed", + OccurredAt: now, + Code: models.LaunchErrorCategoryGenericLaunchFailure, + RecoveryActions: []string{action}, + TaskRepositoryID: taskRepositoryID, + StampValue: "recovery-stamp", + }, + }, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("CreateTask: %v", err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-recovery", WorkspaceID: "ws-recovery", Name: "recovery-repo", LocalPath: t.TempDir(), DefaultBranch: "main", CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: taskRepositoryID, TaskID: taskID, RepositoryID: "repo-recovery", BaseBranch: "main", CheckoutBranch: "feature/recovery", CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("CreateTaskRepository: %v", err) + } +} + +func recoveryFixtureService(t *testing.T, repo *sqliterepo.Repository, fake *taskLaunchRecoveryServiceFake) *Service { + t.Helper() + steps := newMockStepGetter() + steps.steps["review-recovery"] = &wfmodels.WorkflowStep{ID: "review-recovery", WorkflowID: "wf-recovery", Position: 0, Name: "Review"} + steps.steps["done-recovery"] = &wfmodels.WorkflowStep{ID: "done-recovery", WorkflowID: "wf-recovery", Position: 1, Name: "Done"} + svc := createTestService(repo, steps, newMockTaskRepo()) + svc.taskLaunchRecoveryRepo = repo + svc.taskLaunchRecoveryTasks = fake + return svc +} + +func TestRecoverTaskLaunchRejectsStaleStampBeforeMutation(t *testing.T) { + repo := setupTestRepo(t) + seedTaskLaunchRecoveryFixture(t, repo, "task-stale", "task-repo-stale", models.RecoveryActionPickBaseBranch) + fake := &taskLaunchRecoveryServiceFake{branches: []taskservice.Branch{{Name: "main", Type: "remote"}}} + svc := recoveryFixtureService(t, repo, fake) + + _, err := svc.RecoverTaskLaunch(context.Background(), &TaskLaunchRecoveryRequest{ + TaskID: "task-stale", TaskRepositoryID: "task-repo-stale", Action: models.RecoveryActionPickBaseBranch, + BaseBranch: "main", ErrorStamp: "old-stamp", + }) + if !errors.Is(err, ErrTaskLaunchRecoveryStale) { + t.Fatalf("RecoverTaskLaunch error = %v, want stale error", err) + } + if len(fake.updated) != 0 { + t.Fatalf("stale recovery updated repositories: %#v", fake.updated) + } +} + +func TestRecoverTaskLaunchRejectsOlderSessionErrorWhenTaskErrorIsNewer(t *testing.T) { + repo := setupTestRepo(t) + const taskID = "task-newer-task-error" + const taskRepositoryID = "task-repo-newer-task-error" + seedTaskLaunchRecoveryFixture(t, repo, taskID, taskRepositoryID, models.RecoveryActionPickBaseBranch) + + now := time.Now().UTC() + if err := repo.CreateTaskSession(context.Background(), &models.TaskSession{ + ID: "session-older-error", + TaskID: taskID, + State: models.TaskSessionStateFailed, + StartedAt: now.Add(-time.Hour), + UpdatedAt: now.Add(-time.Hour), + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + if err := repo.SetSessionMetadataKey(context.Background(), "session-older-error", models.SessionMetaKeyLastAgentError, models.LastAgentError{ + Message: "older session launch error", + OccurredAt: now.Add(-time.Hour), + Code: models.LaunchErrorCategoryBaseBranchMissing, + RecoveryActions: []string{models.RecoveryActionPickBaseBranch}, + TaskRepositoryID: taskRepositoryID, + StampValue: "older-session-stamp", + }); err != nil { + t.Fatalf("SetSessionMetadataKey: %v", err) + } + + fake := &taskLaunchRecoveryServiceFake{branches: []taskservice.Branch{{Name: "main", Type: "remote"}}} + svc := recoveryFixtureService(t, repo, fake) + _, err := svc.RecoverTaskLaunch(context.Background(), &TaskLaunchRecoveryRequest{ + TaskID: taskID, SessionID: "session-older-error", TaskRepositoryID: taskRepositoryID, + Action: models.RecoveryActionPickBaseBranch, BaseBranch: "main", ErrorStamp: "older-session-stamp", + }) + if !errors.Is(err, ErrTaskLaunchRecoveryStale) { + t.Fatalf("RecoverTaskLaunch error = %v, want stale error", err) + } + if len(fake.updated) != 0 { + t.Fatalf("older session recovery updated repositories: %#v", fake.updated) + } +} + +func TestRecoverTaskLaunchRejectsForeignTaskRepository(t *testing.T) { + repo := setupTestRepo(t) + seedTaskLaunchRecoveryFixture(t, repo, "task-foreign-row", "task-repo-owned", models.RecoveryActionPickBaseBranch) + fake := &taskLaunchRecoveryServiceFake{} + svc := recoveryFixtureService(t, repo, fake) + + _, err := svc.RecoverTaskLaunch(context.Background(), &TaskLaunchRecoveryRequest{ + TaskID: "task-foreign-row", TaskRepositoryID: "task-repo-foreign", Action: models.RecoveryActionPickBaseBranch, + BaseBranch: "main", ErrorStamp: "recovery-stamp", + }) + if err == nil { + t.Fatal("foreign task repository was accepted") + } + if len(fake.updated) != 0 { + t.Fatalf("foreign repository recovery updated repositories: %#v", fake.updated) + } +} + +func TestRecoverTaskLaunchPickBranchValidatesBeforeWriting(t *testing.T) { + repo := setupTestRepo(t) + seedTaskLaunchRecoveryFixture(t, repo, "task-invalid-branch", "task-repo-invalid-branch", models.RecoveryActionPickBaseBranch) + fake := &taskLaunchRecoveryServiceFake{branches: []taskservice.Branch{{Name: "main", Type: "remote"}}} + svc := recoveryFixtureService(t, repo, fake) + + _, err := svc.RecoverTaskLaunch(context.Background(), &TaskLaunchRecoveryRequest{ + TaskID: "task-invalid-branch", TaskRepositoryID: "task-repo-invalid-branch", Action: models.RecoveryActionPickBaseBranch, + BaseBranch: "does-not-exist", ErrorStamp: "recovery-stamp", + }) + if !errors.Is(err, worktree.ErrInvalidBaseBranch) { + t.Fatalf("invalid branch error = %v, want ErrInvalidBaseBranch", err) + } + if len(fake.updated) != 0 { + t.Fatalf("invalid branch updated repositories: %#v", fake.updated) + } +} + +func TestValidateSelectedRecoveryBranchRejectsLocalOnlyBranch(t *testing.T) { + repo := setupTestRepo(t) + fake := &taskLaunchRecoveryServiceFake{branches: []taskservice.Branch{{Name: "main", Type: "local"}}} + svc := recoveryFixtureService(t, repo, fake) + + err := svc.validateSelectedRecoveryBranch(context.Background(), &models.Repository{ID: "repo-recovery", LocalPath: t.TempDir()}, "main") + if !errors.Is(err, worktree.ErrInvalidBaseBranch) { + t.Fatalf("validateSelectedRecoveryBranch(local) = %v, want ErrInvalidBaseBranch", err) + } +} + +func TestRecoverTaskLaunchRecordsUnresolvedDefaultAndDoesNotLaunch(t *testing.T) { + repo := setupTestRepo(t) + seedTaskLaunchRecoveryFixture(t, repo, "task-default-unresolved", "task-repo-default-unresolved", models.RecoveryActionRetryDefault) + fake := &taskLaunchRecoveryServiceFake{} + svc := recoveryFixtureService(t, repo, fake) + svc.taskLaunchRecoveryWorktree = taskLaunchRecoveryWorktreeFake{ + err: fmt.Errorf("token=ghp_abcdefghijklmnopqrstuvwxyz1234567890AB: %w", worktree.ErrRemoteDefaultUnresolved), + } + + _, err := svc.RecoverTaskLaunch(context.Background(), &TaskLaunchRecoveryRequest{ + TaskID: "task-default-unresolved", TaskRepositoryID: "task-repo-default-unresolved", Action: models.RecoveryActionRetryDefault, + ErrorStamp: "recovery-stamp", + }) + if !errors.Is(err, worktree.ErrRemoteDefaultUnresolved) { + t.Fatalf("unresolved default error = %v, want unresolved error", err) + } + if len(fake.updated) != 0 || len(fake.moveTaskIDs) != 0 { + t.Fatalf("unresolved default mutated recovery collaborators: updated=%#v moved=%#v", fake.updated, fake.moveTaskIDs) + } + task, err := repo.GetTask(context.Background(), "task-default-unresolved") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + launchError, ok := models.LoadTaskLaunchError(task.Metadata) + if !ok || launchError.Code != models.LaunchErrorCategoryDefaultBranchUnresolved || !containsRecoveryAction(launchError.RecoveryActions, models.RecoveryActionPickBaseBranch) { + t.Fatalf("stored launch error = %#v, want unresolved default with branch picker", launchError) + } + if launchError.Details == "" || strings.Contains(launchError.Details, "abcdefghijklmnopqrstuvwxyz") { + t.Fatalf("task launch details were not sanitized: %q", launchError.Details) + } +} + +func TestRecoverTaskLaunchDoesNotOverwriteFailureDuringBlockedDefaultResolution(t *testing.T) { + repo := setupTestRepo(t) + const taskID = "task-default-unresolved-stale" + const taskRepositoryID = "task-repo-default-unresolved-stale" + seedTaskLaunchRecoveryFixture(t, repo, taskID, taskRepositoryID, models.RecoveryActionRetryDefault) + fake := &taskLaunchRecoveryServiceFake{} + svc := recoveryFixtureService(t, repo, fake) + started := make(chan struct{}) + release := make(chan struct{}) + svc.taskLaunchRecoveryWorktree = blockingTaskLaunchRecoveryWorktree{ + started: started, + release: release, + err: worktree.ErrRemoteDefaultUnresolved, + } + + result := make(chan error, 1) + go func() { + _, err := svc.RecoverTaskLaunch(context.Background(), &TaskLaunchRecoveryRequest{ + TaskID: taskID, TaskRepositoryID: taskRepositoryID, + Action: models.RecoveryActionRetryDefault, ErrorStamp: "recovery-stamp", + }) + result <- err + }() + <-started + if err := repo.SetTaskMetadataKey(context.Background(), taskID, models.MetaKeyLastLaunchError, models.TaskLaunchError{ + Message: "new provider failure", + OccurredAt: time.Now().UTC(), + Code: models.LaunchErrorCategoryGenericLaunchFailure, + RecoveryActions: []string{models.RecoveryActionRetryDefault}, + TaskRepositoryID: taskRepositoryID, + StampValue: "newer-failure-stamp", + }); err != nil { + t.Fatalf("store newer failure: %v", err) + } + close(release) + + if err := <-result; !errors.Is(err, ErrTaskLaunchRecoveryStale) { + t.Fatalf("blocked recovery error = %v, want stale error", err) + } + task, err := repo.GetTask(context.Background(), taskID) + if err != nil { + t.Fatalf("load task after stale recovery: %v", err) + } + launchError, ok := models.LoadTaskLaunchError(task.Metadata) + if !ok || launchError.Stamp() != "newer-failure-stamp" { + t.Fatalf("task launch error = %#v, want newer failure", launchError) + } +} + +func TestRecoverTaskLaunchRecordsUnresolvedDefaultForSessionSource(t *testing.T) { + repo := setupTestRepo(t) + const ( + taskID = "task-session-default-unresolved" + taskRepositoryID = "task-repo-session-default-unresolved" + sessionID = "session-default-unresolved" + ) + seedTaskLaunchRecoveryFixture(t, repo, taskID, taskRepositoryID, models.RecoveryActionRetryDefault) + if removed, err := repo.RemoveTaskMetadataKeyIfStamp(context.Background(), taskID, models.MetaKeyLastLaunchError, "recovery-stamp"); err != nil || !removed { + t.Fatalf("remove task launch error: removed=%v err=%v", removed, err) + } + if err := repo.CreateTaskSession(context.Background(), &models.TaskSession{ + ID: sessionID, TaskID: taskID, State: models.TaskSessionStateFailed, + Metadata: map[string]interface{}{ + models.SessionMetaKeyLastAgentError: models.LastAgentError{ + Message: "session launch failed", + OccurredAt: time.Now().UTC(), + Code: models.LaunchErrorCategoryBaseBranchMissing, + RecoveryActions: []string{models.RecoveryActionRetryDefault}, + TaskRepositoryID: taskRepositoryID, + StampValue: "session-recovery-stamp", + }, + }, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + + fake := &taskLaunchRecoveryServiceFake{} + svc := recoveryFixtureService(t, repo, fake) + svc.taskLaunchRecoveryWorktree = taskLaunchRecoveryWorktreeFake{ + err: fmt.Errorf("token=ghp_abcdefghijklmnopqrstuvwxyz1234567890AB: %w", worktree.ErrRemoteDefaultUnresolved), + } + _, err := svc.RecoverTaskLaunch(context.Background(), &TaskLaunchRecoveryRequest{ + TaskID: taskID, SessionID: sessionID, TaskRepositoryID: taskRepositoryID, + Action: models.RecoveryActionRetryDefault, ErrorStamp: "session-recovery-stamp", + }) + if !errors.Is(err, worktree.ErrRemoteDefaultUnresolved) { + t.Fatalf("session unresolved default error = %v, want unresolved error", err) + } + updated, err := repo.GetTaskSession(context.Background(), sessionID) + if err != nil { + t.Fatalf("GetTaskSession: %v", err) + } + lastError, ok := models.LoadLastAgentError(updated.Metadata) + if !ok || lastError.Code != models.LaunchErrorCategoryDefaultBranchUnresolved { + t.Fatalf("stored session launch error = %#v, want unresolved default", lastError) + } + if containsRecoveryAction(lastError.RecoveryActions, models.RecoveryActionRetryDefault) { + t.Fatal("unresolved session error still offers retry_default") + } + if !containsRecoveryAction(lastError.RecoveryActions, models.RecoveryActionPickBaseBranch) { + t.Fatal("unresolved session error does not offer branch selection") + } + if len(lastError.Details) == 0 || lastError.Details == "token=ghp_abcdefghijklmnopqrstuvwxyz1234567890AB" { + t.Fatalf("session launch details were not sanitized: %q", lastError.Details) + } +} + +func TestRecoverTaskLaunchMarksReviewDoneAfterTerminalPRRecheck(t *testing.T) { + repo := setupTestRepo(t) + seedTaskLaunchRecoveryFixture(t, repo, "task-mark-done", "task-repo-mark-done", models.RecoveryActionMarkReviewDone) + fake := &taskLaunchRecoveryServiceFake{} + svc := recoveryFixtureService(t, repo, fake) + svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{{TaskID: "task-mark-done", RepositoryID: "repo-recovery", PRNumber: 42, State: githubPRStateMerged}}}) + if err := repo.UpdateTaskRepository(context.Background(), &models.TaskRepository{ + ID: "task-repo-mark-done", TaskID: "task-mark-done", RepositoryID: "repo-recovery", BaseBranch: "main", CheckoutBranch: "feature/recovery", + Metadata: map[string]interface{}{"pr_number": float64(42)}, + }); err != nil { + t.Fatalf("UpdateTaskRepository: %v", err) + } + + response, err := svc.RecoverTaskLaunch(context.Background(), &TaskLaunchRecoveryRequest{ + TaskID: "task-mark-done", Action: models.RecoveryActionMarkReviewDone, ErrorStamp: "recovery-stamp", + }) + if err != nil { + t.Fatalf("RecoverTaskLaunch: %v", err) + } + if !response.OK || response.TaskID != "task-mark-done" { + t.Fatalf("response = %#v, want successful task response", response) + } + if len(fake.moveTaskIDs) != 1 || fake.moveTaskIDs[0] != "task-mark-done" { + t.Fatalf("move calls = %#v, want one terminal move", fake.moveTaskIDs) + } + if len(fake.moved) != 1 || !fake.moved[0].AllowFailedToCompletedRecovery { + t.Fatalf("move options = %#v, want recovery-only completion", fake.moved) + } + task, err := repo.GetTask(context.Background(), "task-mark-done") + if err != nil { + t.Fatalf("GetTask after mark done: %v", err) + } + if _, ok := models.LoadTaskLaunchError(task.Metadata); ok { + t.Fatal("mark_review_done did not clear the stamped task error") + } +} + +func TestRecoverTaskLaunchRejectsOpenPRWithoutMutation(t *testing.T) { + repo := setupTestRepo(t) + seedTaskLaunchRecoveryFixture(t, repo, "task-open-pr", "task-repo-open-pr", models.RecoveryActionMarkReviewDone) + fake := &taskLaunchRecoveryServiceFake{} + svc := recoveryFixtureService(t, repo, fake) + svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{{TaskID: "task-open-pr", RepositoryID: "repo-recovery", PRNumber: 42, State: githubPRStateOpen}}}) + if err := repo.UpdateTaskRepository(context.Background(), &models.TaskRepository{ + ID: "task-repo-open-pr", TaskID: "task-open-pr", RepositoryID: "repo-recovery", BaseBranch: "main", CheckoutBranch: "feature/recovery", + Metadata: map[string]interface{}{"pr_number": float64(42)}, + }); err != nil { + t.Fatalf("UpdateTaskRepository: %v", err) + } + + _, err := svc.RecoverTaskLaunch(context.Background(), &TaskLaunchRecoveryRequest{ + TaskID: "task-open-pr", Action: models.RecoveryActionMarkReviewDone, ErrorStamp: "recovery-stamp", + }) + if err == nil { + t.Fatal("mark_review_done accepted an open PR") + } + if len(fake.moveTaskIDs) != 0 { + t.Fatalf("open PR caused a move: %#v", fake.moveTaskIDs) + } +} diff --git a/apps/backend/internal/orchestrator/task_launch_workflow.go b/apps/backend/internal/orchestrator/task_launch_workflow.go new file mode 100644 index 0000000000..5db14bfdef --- /dev/null +++ b/apps/backend/internal/orchestrator/task_launch_workflow.go @@ -0,0 +1,39 @@ +package orchestrator + +import ( + "context" + "fmt" + "strings" + + wfmodels "github.com/kandev/kandev/internal/workflow/models" +) + +// findFinalWorkflowStep follows the workflow from stepID to its terminal +// position. A nil result means that the workflow has no valid terminal step. +func findFinalWorkflowStep( + ctx context.Context, + getter WorkflowStepGetter, + stepID string, +) (*wfmodels.WorkflowStep, error) { + if getter == nil || strings.TrimSpace(stepID) == "" { + return nil, nil + } + step, err := getter.GetStep(ctx, stepID) + if err != nil || step == nil { + return nil, err + } + for range 1000 { + next, nextErr := getter.GetNextStepByPosition(ctx, step.WorkflowID, step.Position) + if nextErr != nil { + return nil, nextErr + } + if next == nil { + if wfmodels.IsTerminalStep(step, nil) { + return step, nil + } + return nil, nil + } + step = next + } + return nil, fmt.Errorf("workflow final step traversal exceeded limit") +} diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 8c0fc141a3..3b9c9c0278 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -640,23 +640,15 @@ func (s *Service) handleSessionLaunchFailure( return safeErr } -// recordSessionLaunchFailure transitions a still-active session to FAILED, -// attaches missing-branch guidance only after that CAS succeeds, and updates -// the task only while the same failed session still owns it. +// recordSessionLaunchFailure transitions a still-active session to FAILED and +// updates the task only while the same failed session still owns it. Typed +// launch-error persistence belongs to the executor's session transition. func (s *Service) recordSessionLaunchFailure(ctx context.Context, taskID, sessionID string, launchErr error, preloadedSession ...*models.TaskSession) { _, changed := s.updateTaskSessionStateWithHook( ctx, taskID, sessionID, models.TaskSessionStateFailed, launchErr.Error(), false, - func() { s.handleSessionLaunchFailed(ctx, taskID, sessionID, "", launchErr) }, preloadedSession..., + nil, preloadedSession..., ) if !changed { - // A resume may begin from an already FAILED session. Its state CAS is - // intentionally a no-op, but the persisted claim still lets this newly - // classified failure publish guidance exactly once. The claim itself is - // state-guarded, so a concurrently cancelled/completed session cannot - // receive stale recovery UI. - if len(preloadedSession) > 0 && preloadedSession[0] != nil && preloadedSession[0].State == models.TaskSessionStateFailed { - s.handleSessionLaunchFailed(ctx, taskID, sessionID, "", launchErr) - } return } updated, err := s.updateTaskStateForEarlyLaunchFailure(ctx, taskID, sessionID) @@ -866,6 +858,17 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s return nil, err } } + // Automated launch callers perform this check before entering startTask, + // but several event paths can race after that check and before session + // preparation. Keep the terminal-PR guard at the session boundary too so + // no automated path can create a session for a closed or merged PR. + if autoStart { + if task, taskErr := s.repo.GetTask(ctx, taskID); taskErr != nil { + return nil, fmt.Errorf("failed to fetch task for automatic launch gate: %w", taskErr) + } else if s.shouldSkipTerminalPRAutoStart(ctx, task) { + return nil, nil + } + } workflowSessionConfigStepID := workflowStepID // Manual starts often omit workflow_step_id because the task is already // bound to its current step. Resolve that canonical step before profile @@ -929,6 +932,10 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s zap.Error(err)) return nil, err } + launchErrorStamp := "" + if launchError, found := models.LoadTaskLaunchError(task.Metadata); found { + launchErrorStamp = launchError.Stamp() + } if executorID == "" { if v, ok := task.Metadata[models.MetaKeyExecutorID].(string); ok && v != "" { executorID = v @@ -1080,6 +1087,7 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s } s.postLaunchStart(ctx, taskID, execution, effectivePrompt, planModeActive || configMode, planModeActive, autoStart, attachments) + s.clearTaskLaunchErrorIfStamp(ctx, taskID, launchErrorStamp) // The agent is running, so the reservation becomes a consumption. launchClaim.consume(ctx) @@ -2982,6 +2990,15 @@ func (s *Service) publishTaskSessionErrorEvent( eventData["occurred_at"] = lastError.OccurredAt.Format(time.RFC3339Nano) eventData["stamp"] = lastError.Stamp() eventData["agent_execution_id"] = lastError.AgentExecutionID + if lastError.TaskRepositoryID != "" { + eventData["task_repository_id"] = lastError.TaskRepositoryID + } + if lastError.Code != "" { + eventData["category"] = lastError.Code + } + if len(lastError.RecoveryActions) > 0 { + eventData["recovery_actions"] = append([]string(nil), lastError.RecoveryActions...) + } if lastError.RemediationURL != "" { eventData["remediation_url"] = lastError.RemediationURL } diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index c42b873d48..6bfc82bab5 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -3423,6 +3423,28 @@ func TestCancelIntentDoesNotFollowSharedGuard(t *testing.T) { // --- StartCreatedSession --- +func requirePersistedSessionLaunchError(t *testing.T, repo *sqliterepo.Repository, sessionID string) models.LastAgentError { + t.Helper() + session, err := repo.GetTaskSession(context.Background(), sessionID) + if err != nil { + t.Fatalf("GetTaskSession: %v", err) + } + lastError, ok := models.LoadLastAgentError(session.Metadata) + if !ok { + t.Fatalf("session %q has no typed launch error: %#v", sessionID, session.Metadata) + } + if lastError.Code == "" || lastError.Stamp() == "" { + t.Fatalf("session %q has incomplete typed launch error: %#v", sessionID, lastError) + } + return lastError +} + +func sessionMessageCount(messages *mockMessageCreator) int { + messages.mu.Lock() + defer messages.mu.Unlock() + return len(messages.sessionMessages) +} + func TestStartCreatedSession_WrongTask(t *testing.T) { repo := setupTestRepo(t) svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) @@ -3448,7 +3470,7 @@ func TestStartCreatedSession_NotInCreatedState(t *testing.T) { } } -func TestStartCreatedSession_MissingRemoteRefCreatesNeutralRecoveryMessage(t *testing.T) { +func TestStartCreatedSession_MissingRemoteRefPersistsTypedRecoveryError(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCreated) @@ -3469,7 +3491,6 @@ func TestStartCreatedSession_MissingRemoteRefCreatesNeutralRecoveryMessage(t *te svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr) messages := &mockMessageCreator{} svc.messageCreator = messages - svc.executor.SetOnLaunchFailed(svc.handleSessionLaunchFailed) svc.executor.SetOnSessionStateChange(func(callbackCtx context.Context, callbackTaskID, callbackSessionID string, state models.TaskSessionState, errorMessage string) error { svc.updateTaskSessionState(callbackCtx, callbackTaskID, callbackSessionID, state, errorMessage, true) return nil @@ -3480,19 +3501,16 @@ func TestStartCreatedSession_MissingRemoteRefCreatesNeutralRecoveryMessage(t *te t.Fatalf("StartCreatedSession error = %v, want %v", err, launchErr) } - if len(messages.sessionMessages) != 1 { - t.Fatalf("expected one recovery message, got %d", len(messages.sessionMessages)) + if got := sessionMessageCount(messages); got != 0 { + t.Fatalf("typed launch failure created legacy guidance messages: %d", got) } - message := messages.sessionMessages[0] - if message.metadata["failure_kind"] != "branch_fetch_failed" { - t.Fatalf("failure_kind = %#v, want branch_fetch_failed", message.metadata["failure_kind"]) - } - if _, ok := message.metadata["actions"]; ok { - t.Fatalf("expected neutral guidance without archive/delete actions, got %#v", message.metadata["actions"]) + if _, suppressed := svc.suppressToast.Load("session1"); suppressed { + t.Fatal("typed launch failure must not suppress the pointer toast") } + requirePersistedSessionLaunchError(t, repo, "session1") } -func TestStartCreatedSession_MissingRemoteRefDoesNotDuplicateExecutorRecoveryMessage(t *testing.T) { +func TestStartCreatedSession_MissingRemoteRefDoesNotDuplicateTypedRecoveryError(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCreated) @@ -3514,21 +3532,19 @@ func TestStartCreatedSession_MissingRemoteRefDoesNotDuplicateExecutorRecoveryMes messages := &mockMessageCreator{} svc.messageCreator = messages svc.eventBus = bus.NewMemoryEventBus(testLogger()) - svc.executor.SetOnLaunchFailed(svc.handleSessionLaunchFailed) - // Production routes terminal failures through the strict transition callback - // so recovery guidance can set suppressToast before state_changed publishes it. svc.executor.SetOnSessionStateTransition(svc.transitionTaskSessionState) _, err := svc.StartCreatedSession(ctx, "task1", "session1", "profile1", "start the task", true, false, true, nil, nil) if !errors.Is(err, launchErr) { t.Fatalf("StartCreatedSession error = %v, want %v", err, launchErr) } - if len(messages.sessionMessages) != 1 { - t.Fatalf("expected exactly one recovery message, got %d", len(messages.sessionMessages)) + if got := sessionMessageCount(messages); got != 0 { + t.Fatalf("typed launch failure created legacy guidance messages: %d", got) } if _, suppressed := svc.suppressToast.Load("session1"); suppressed { - t.Fatal("state-change publishing did not consume the toast-suppression marker") + t.Fatal("typed launch failure must not suppress the pointer toast") } + requirePersistedSessionLaunchError(t, repo, "session1") } func TestPrepareTaskSession_WorkspaceLaunchFailureRecovery(t *testing.T) { @@ -3547,7 +3563,7 @@ func TestPrepareTaskSession_WorkspaceLaunchFailureRecovery(t *testing.T) { } } - t.Run("early missing remote ref creates one neutral recovery message", func(t *testing.T) { + t.Run("early missing remote ref stays neutral before executor classification", func(t *testing.T) { baseRepo := setupTestRepo(t) seedTaskAndSession(t, baseRepo, taskID, "existing-session", models.TaskSessionStateCreated) failureRepo := &taskEnvironmentFailureRepo{ @@ -3578,24 +3594,6 @@ func TestPrepareTaskSession_WorkspaceLaunchFailureRecovery(t *testing.T) { t.Fatalf("PrepareTaskSession: %v", err) } - select { - case <-messages.sessionMessageDone: - case <-time.After(time.Second): - t.Fatal("timed out waiting for missing-branch recovery message") - } - if len(messages.sessionMessages) != 1 { - t.Fatalf("expected exactly one recovery message, got %d", len(messages.sessionMessages)) - } - message := messages.sessionMessages[0] - if message.sessionID != sessionID { - t.Fatalf("recovery message session = %q, want %q", message.sessionID, sessionID) - } - if message.metadata["failure_kind"] != "branch_fetch_failed" { - t.Fatalf("failure_kind = %#v, want branch_fetch_failed", message.metadata["failure_kind"]) - } - if _, ok := message.metadata["actions"]; ok { - t.Fatalf("expected neutral guidance without archive/delete actions, got %#v", message.metadata["actions"]) - } require.Eventually(t, func() bool { failedSession, getErr := baseRepo.GetTaskSession(context.Background(), sessionID) return getErr == nil && failedSession.State == models.TaskSessionStateFailed @@ -3606,7 +3604,10 @@ func TestPrepareTaskSession_WorkspaceLaunchFailureRecovery(t *testing.T) { return taskRepo.updatedStates[taskID] == v1.TaskStateFailed }, time.Second, 10*time.Millisecond, "expected early launch failure to mark the task FAILED") if _, suppressed := svc.suppressToast.Load(sessionID); suppressed { - t.Fatal("missing-branch recovery left a stale toast-suppression marker") + t.Fatal("typed launch failure must not suppress the pointer toast") + } + if got := sessionMessageCount(messages); got != 0 { + t.Fatalf("typed launch failure created legacy guidance messages: %d", got) } }) @@ -3674,30 +3675,25 @@ func TestPrepareTaskSession_WorkspaceLaunchFailureRecovery(t *testing.T) { }, } svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr) - svc.executor.SetOnLaunchFailed(svc.handleSessionLaunchFailed) svc.eventBus = bus.NewMemoryEventBus(testLogger()) svc.executor.SetOnSessionStateChange(func(callbackCtx context.Context, callbackTaskID, callbackSessionID string, state models.TaskSessionState, errorMessage string) error { svc.updateTaskSessionState(callbackCtx, callbackTaskID, callbackSessionID, state, errorMessage, true) return nil }) - messages := &mockMessageCreator{sessionMessageDone: make(chan struct{})} + messages := &mockMessageCreator{} svc.messageCreator = messages sessionID, err := svc.PrepareTaskSession(context.Background(), taskID, "profile1", "", "", "", true) if err != nil { t.Fatalf("PrepareTaskSession: %v", err) } - select { - case <-messages.sessionMessageDone: - case <-time.After(time.Second): - t.Fatal("timed out waiting for executor recovery message") - } require.Eventually(t, func() bool { session, getErr := repo.GetTaskSession(context.Background(), sessionID) return getErr == nil && session.State == models.TaskSessionStateFailed }, time.Second, 10*time.Millisecond, "expected failed state after workspace launch error") - if len(messages.sessionMessages) != 1 { - t.Fatalf("expected exactly one recovery message, got %d", len(messages.sessionMessages)) + requirePersistedSessionLaunchError(t, repo, sessionID) + if got := sessionMessageCount(messages); got != 0 { + t.Fatalf("typed launch failure created legacy guidance messages: %d", got) } }) } @@ -3974,8 +3970,8 @@ func TestHandleSessionLaunchFailure_ConcurrentLaunchesCreateOneMessage(t *testin messages.mu.Lock() messageCount := len(messages.sessionMessages) messages.mu.Unlock() - if messageCount != 1 { - t.Fatalf("recovery message count = %d, want 1", messageCount) + if messageCount != 0 { + t.Fatalf("legacy recovery message count = %d, want 0", messageCount) } taskRepo.mu.Lock() defer taskRepo.mu.Unlock() @@ -4006,7 +4002,6 @@ func TestPrepareAndStartCreatedSession_MissingRemoteRefClaimsRecoveryOnce(t *tes svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr) svc.eventBus = bus.NewMemoryEventBus(testLogger()) svc.executor.SetOnSessionStateTransition(svc.transitionTaskSessionState) - svc.executor.SetOnLaunchFailed(svc.handleSessionLaunchFailed) messages := &mockMessageCreator{sessionMessageDone: make(chan struct{})} svc.messageCreator = messages @@ -4031,12 +4026,6 @@ func TestPrepareAndStartCreatedSession_MissingRemoteRefClaimsRecoveryOnce(t *tes case <-time.After(time.Second): t.Fatal("StartCreatedSession did not settle after prepared launch failed") } - select { - case <-messages.sessionMessageDone: - case <-time.After(time.Second): - t.Fatal("timed out waiting for missing-branch recovery message") - } - failed, err := repo.GetTaskSession(ctx, sessionID) if err != nil { t.Fatalf("GetTaskSession: %v", err) @@ -4046,8 +4035,8 @@ func TestPrepareAndStartCreatedSession_MissingRemoteRefClaimsRecoveryOnce(t *tes } messages.mu.Lock() defer messages.mu.Unlock() - if len(messages.sessionMessages) != 1 { - t.Fatalf("recovery message count = %d, want 1", len(messages.sessionMessages)) + if len(messages.sessionMessages) != 0 { + t.Fatalf("legacy recovery message count = %d, want 0", len(messages.sessionMessages)) } } @@ -5635,15 +5624,8 @@ func TestResumeTaskSession_AlreadyFailedMissingRemoteRefCreatesNeutralRecoveryMe } messages := svc.messageCreator.(*mockMessageCreator).sessionMessages - if len(messages) != 1 { - t.Fatalf("expected one recovery message, got %d", len(messages)) - } - message := messages[0] - if message.metadata["failure_kind"] != "branch_fetch_failed" { - t.Fatalf("failure_kind = %#v, want branch_fetch_failed", message.metadata["failure_kind"]) - } - if _, ok := message.metadata["actions"]; ok { - t.Fatalf("expected no destructive actions without a repository-scoped PR match, got %#v", message.metadata["actions"]) + if len(messages) != 0 { + t.Fatalf("typed launch failure created legacy guidance messages: %d", len(messages)) } taskRepo.mu.Lock() defer taskRepo.mu.Unlock() diff --git a/apps/backend/internal/task/models/launch_errors.go b/apps/backend/internal/task/models/launch_errors.go new file mode 100644 index 0000000000..874c772b5d --- /dev/null +++ b/apps/backend/internal/task/models/launch_errors.go @@ -0,0 +1,212 @@ +package models + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + "time" + "unicode/utf8" +) + +// MetaKeyLastLaunchError stores the task-owned launch failure. Unlike the +// session error, this record survives session creation and remains visible +// when a launch never created a session. +const MetaKeyLastLaunchError = "last_launch_error" + +// Launch error categories are stable wire and persistence values. Keep these +// values independent from human-readable messages. +const ( + LaunchErrorCategoryBaseBranchMissing = "base_branch_missing" + LaunchErrorCategoryPRAlreadyClosed = "pr_already_closed" + LaunchErrorCategoryDefaultBranchUnresolved = "default_branch_unresolved" + LaunchErrorCategoryGenericLaunchFailure = "generic_launch_failure" +) + +// Recovery actions are stable wire values shared by backend and frontend. +const ( + RecoveryActionRetryDefault = "retry_default" + RecoveryActionPickBaseBranch = "pick_base_branch" + RecoveryActionMarkReviewDone = "mark_review_done" +) + +const ( + maxLaunchErrorRecoveryActions = 3 + maxLaunchErrorMessageBytes = 4096 + maxLaunchErrorStampBytes = 256 + maxLaunchErrorCategoryBytes = 64 + maxLaunchErrorDetailsBytes = 4096 + maxTaskRepositoryIDBytes = 256 +) + +// TaskLaunchError is persisted under Task.Metadata[MetaKeyLastLaunchError]. +// It is intentionally bounded because task metadata is returned in boot and +// task-list payloads. +type TaskLaunchError struct { + Message string `json:"message"` + OccurredAt time.Time `json:"occurred_at"` + Code string `json:"code,omitempty"` + Details string `json:"details,omitempty"` + RecoveryActions []string `json:"recovery_actions,omitempty"` + TaskRepositoryID string `json:"task_repository_id,omitempty"` + StampValue string `json:"stamp,omitempty"` +} + +// NormalizeRecoveryActions removes unknown, duplicate, and excess actions +// before a record crosses a persistence or WebSocket boundary. +func NormalizeRecoveryActions(actions []string) []string { + seen := make(map[string]struct{}, len(actions)) + result := make([]string, 0, min(len(actions), maxLaunchErrorRecoveryActions)) + for _, action := range actions { + action = strings.TrimSpace(action) + if !isKnownRecoveryAction(action) { + continue + } + if _, exists := seen[action]; exists { + continue + } + seen[action] = struct{}{} + result = append(result, action) + if len(result) == maxLaunchErrorRecoveryActions { + break + } + } + return result +} + +func isKnownRecoveryAction(action string) bool { + switch action { + case RecoveryActionRetryDefault, RecoveryActionPickBaseBranch, RecoveryActionMarkReviewDone: + return true + default: + return false + } +} + +func normalizeLastAgentError(value LastAgentError) LastAgentError { + value.Message = truncateUTF8Bytes(value.Message, maxLaunchErrorMessageBytes) + value.RecoveryActions = NormalizeRecoveryActions(value.RecoveryActions) + value.TaskRepositoryID = truncateUTF8Bytes(value.TaskRepositoryID, maxTaskRepositoryIDBytes) + value.StampValue = boundedLaunchErrorStamp(value.StampValue) + value.Details = truncateUTF8Bytes(value.Details, maxLaunchErrorDetailsBytes) + return value +} + +func normalizeTaskLaunchError(value TaskLaunchError) TaskLaunchError { + value.Message = truncateUTF8Bytes(value.Message, maxLaunchErrorMessageBytes) + value.RecoveryActions = NormalizeRecoveryActions(value.RecoveryActions) + value.TaskRepositoryID = truncateUTF8Bytes(value.TaskRepositoryID, maxTaskRepositoryIDBytes) + value.StampValue = boundedLaunchErrorStamp(value.StampValue) + value.Code = truncateUTF8Bytes(value.Code, maxLaunchErrorCategoryBytes) + value.Details = truncateUTF8Bytes(value.Details, maxLaunchErrorDetailsBytes) + return value +} + +// LoadTaskLaunchError reads and validates the task-owned launch error. +func LoadTaskLaunchError(metadata map[string]interface{}) (TaskLaunchError, bool) { + if metadata == nil { + return TaskLaunchError{}, false + } + raw, ok := metadata[MetaKeyLastLaunchError] + if !ok || raw == nil { + return TaskLaunchError{}, false + } + data, err := json.Marshal(raw) + if err != nil { + return TaskLaunchError{}, false + } + var value TaskLaunchError + if err := json.Unmarshal(data, &value); err != nil || strings.TrimSpace(value.Message) == "" { + return TaskLaunchError{}, false + } + return normalizeTaskLaunchError(value), true +} + +// SetTaskLaunchError writes a task launch error unless the current record has +// the same stamp. The no-op preserves the first occurrence time for a PR +// state while repeated lifecycle callbacks race. +func SetTaskLaunchError(metadata map[string]interface{}, value TaskLaunchError) bool { + if metadata == nil || strings.TrimSpace(value.Message) == "" { + return false + } + value = normalizeTaskLaunchError(value) + if current, ok := LoadTaskLaunchError(metadata); ok && value.Stamp() != "" && current.MatchesStamp(value.Stamp()) { + return false + } + metadata[MetaKeyLastLaunchError] = value + return true +} + +// ClearTaskLaunchError removes the current record only when expectedStamp +// still identifies it. This protects a newer launch failure from an older +// successful retry callback. +func ClearTaskLaunchError(metadata map[string]interface{}, expectedStamp string) bool { + if metadata == nil || strings.TrimSpace(expectedStamp) == "" { + return false + } + current, ok := LoadTaskLaunchError(metadata) + if !ok || !current.MatchesStamp(expectedStamp) { + return false + } + delete(metadata, MetaKeyLastLaunchError) + return true +} + +// Stamp returns the explicit bounded stamp, or the legacy computed form for +// records written before the explicit field existed. +func (e TaskLaunchError) Stamp() string { + if stamp := boundedLaunchErrorStamp(e.StampValue); stamp != "" { + return stamp + } + return e.OccurredAt.UTC().Format(time.RFC3339Nano) + ":" + e.Message +} + +// MatchesStamp reports whether stamp identifies this launch error. +func (e TaskLaunchError) MatchesStamp(stamp string) bool { + if stamp == e.Stamp() { + return true + } + if boundedLaunchErrorStamp(e.StampValue) != "" { + return false + } + suffix := ":" + e.Message + if !strings.HasSuffix(stamp, suffix) { + return false + } + rawOccurredAt := strings.TrimSuffix(stamp, suffix) + if rawOccurredAt == "" { + return e.OccurredAt.IsZero() + } + occurredAt, err := time.Parse(time.RFC3339Nano, rawOccurredAt) + return err == nil && occurredAt.Equal(e.OccurredAt) +} + +// StableLaunchErrorStamp creates a short deterministic identity for a +// normalized set of external states, such as repository and PR state. +func StableLaunchErrorStamp(parts ...string) string { + hash := sha256.New() + for _, part := range parts { + hash.Write([]byte(part)) + hash.Write([]byte{0}) + } + sum := hash.Sum(nil) + return hex.EncodeToString(sum[:16]) +} + +func boundedLaunchErrorStamp(value string) string { + return truncateUTF8Bytes(strings.TrimSpace(value), maxLaunchErrorStampBytes) +} + +func truncateUTF8Bytes(value string, maxBytes int) string { + if maxBytes <= 0 || value == "" { + return "" + } + if len(value) <= maxBytes { + return value + } + value = value[:maxBytes] + for !utf8.ValidString(value) { + value = value[:len(value)-1] + } + return value +} diff --git a/apps/backend/internal/task/models/launch_errors_test.go b/apps/backend/internal/task/models/launch_errors_test.go new file mode 100644 index 0000000000..505e680c3b --- /dev/null +++ b/apps/backend/internal/task/models/launch_errors_test.go @@ -0,0 +1,110 @@ +package models + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestLoadLastAgentErrorUsesExplicitStampAndNormalizesRecoveryFields(t *testing.T) { + metadata := map[string]interface{}{ + SessionMetaKeyLastAgentError: map[string]interface{}{ + "message": "base branch is missing", + "occurred_at": "2026-08-19T10:00:00Z", + "stamp": "pr-state-1", + "task_repository_id": "task-repo-1", + "recovery_actions": []string{"retry_default", "retry_default", "unknown", "pick_base_branch", "mark_review_done"}, + }, + } + + lastError, ok := LoadLastAgentError(metadata) + require.True(t, ok) + require.Equal(t, "pr-state-1", lastError.Stamp()) + require.Equal(t, "task-repo-1", lastError.TaskRepositoryID) + require.Equal(t, []string{ + RecoveryActionRetryDefault, + RecoveryActionPickBaseBranch, + RecoveryActionMarkReviewDone, + }, lastError.RecoveryActions) +} + +func TestTaskLaunchErrorStoreIsNoOpForSameStamp(t *testing.T) { + firstTime := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC) + secondTime := firstTime.Add(time.Minute) + metadata := map[string]interface{}{} + + first := TaskLaunchError{ + Message: "base branch is missing", + OccurredAt: firstTime, + StampValue: "pr-state-1", + TaskRepositoryID: "task-repo-1", + RecoveryActions: []string{RecoveryActionRetryDefault}, + } + require.True(t, SetTaskLaunchError(metadata, first)) + + second := first + second.OccurredAt = secondTime + second.Message = "a newer message for the same PR state" + require.False(t, SetTaskLaunchError(metadata, second)) + + stored, ok := LoadTaskLaunchError(metadata) + require.True(t, ok) + require.Equal(t, firstTime, stored.OccurredAt) + require.Equal(t, first.Message, stored.Message) + require.Equal(t, first.StampValue, stored.Stamp()) +} + +func TestTaskLaunchErrorClearRequiresMatchingStamp(t *testing.T) { + metadata := map[string]interface{}{} + require.True(t, SetTaskLaunchError(metadata, TaskLaunchError{ + Message: "base branch is missing", + OccurredAt: time.Now().UTC(), + StampValue: "pr-state-1", + })) + + require.False(t, ClearTaskLaunchError(metadata, "pr-state-old")) + require.True(t, ClearTaskLaunchError(metadata, "pr-state-1")) + _, ok := LoadTaskLaunchError(metadata) + require.False(t, ok) +} + +func TestStableLaunchErrorStampIsDeterministicAndBounded(t *testing.T) { + first := StableLaunchErrorStamp("repo-1", "123", "open") + second := StableLaunchErrorStamp("repo-1", "123", "open") + require.Equal(t, first, second) + require.Len(t, first, 32) + require.NotEqual(t, first, StableLaunchErrorStamp("repo-1", "123", "closed")) +} + +func TestLaunchErrorNormalizationBoundsPersistedMessages(t *testing.T) { + longMessage := strings.Repeat("x", maxLaunchErrorMessageBytes+100) + + taskMetadata := map[string]interface{}{ + MetaKeyLastLaunchError: TaskLaunchError{Message: longMessage}, + } + taskError, ok := LoadTaskLaunchError(taskMetadata) + require.True(t, ok) + require.Len(t, taskError.Message, maxLaunchErrorMessageBytes) + + sessionMetadata := map[string]interface{}{ + SessionMetaKeyLastAgentError: LastAgentError{Message: longMessage}, + } + sessionError, ok := LoadLastAgentError(sessionMetadata) + require.True(t, ok) + require.Len(t, sessionError.Message, maxLaunchErrorMessageBytes) +} + +func TestLaunchErrorMatchesStampDoesNotFallBackWhenExplicitStampExists(t *testing.T) { + occurredAt := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC) + legacyStamp := occurredAt.Format(time.RFC3339Nano) + ":boom" + + taskError := TaskLaunchError{Message: "boom", OccurredAt: occurredAt, StampValue: "explicit-task"} + require.True(t, taskError.MatchesStamp("explicit-task")) + require.False(t, taskError.MatchesStamp(legacyStamp)) + + sessionError := LastAgentError{Message: "boom", OccurredAt: occurredAt, StampValue: "explicit-session"} + require.True(t, sessionError.MatchesStamp("explicit-session")) + require.False(t, sessionError.MatchesStamp(legacyStamp)) +} diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 6da3244384..fa9e8288ca 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -544,6 +544,9 @@ type LastAgentError struct { RemediationURL string `json:"remediation_url,omitempty"` Code string `json:"code,omitempty"` Details string `json:"details,omitempty"` + RecoveryActions []string `json:"recovery_actions,omitempty"` + TaskRepositoryID string `json:"task_repository_id,omitempty"` + StampValue string `json:"stamp,omitempty"` DismissedAt *time.Time `json:"dismissed_at,omitempty"` } @@ -559,7 +562,7 @@ func LoadLastAgentError(metadata map[string]interface{}) (LastAgentError, bool) if err := mapToLastAgentError(raw, &out); err != nil || out.Message == "" { return LastAgentError{}, false } - return out, true + return normalizeLastAgentError(out), true } func mapToLastAgentError(raw interface{}, out *LastAgentError) error { @@ -571,6 +574,9 @@ func mapToLastAgentError(raw interface{}, out *LastAgentError) error { } func (e LastAgentError) Stamp() string { + if stamp := boundedLaunchErrorStamp(e.StampValue); stamp != "" { + return stamp + } return e.OccurredAt.UTC().Format(time.RFC3339Nano) + ":" + e.Message } @@ -578,6 +584,9 @@ func (e LastAgentError) MatchesStamp(stamp string) bool { if stamp == e.Stamp() { return true } + if boundedLaunchErrorStamp(e.StampValue) != "" { + return false + } suffix := ":" + e.Message if !strings.HasSuffix(stamp, suffix) { return false diff --git a/apps/backend/internal/task/repository/sqlite/metadata_launch_error_cas.go b/apps/backend/internal/task/repository/sqlite/metadata_launch_error_cas.go new file mode 100644 index 0000000000..cd36fcf606 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/metadata_launch_error_cas.go @@ -0,0 +1,176 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db/dialect" +) + +type metadataCASMode uint8 + +const ( + metadataCASExpected metadataCASMode = iota + metadataCASDifferent +) + +// SetSessionMetadataKeyIfStamp replaces one session metadata value only when +// the current value still has expectedStamp. The row lock and write share one +// transaction, so a recovery request cannot replace a newer error. +func (r *Repository) SetSessionMetadataKeyIfStamp( + ctx context.Context, + sessionID, key, expectedStamp string, + value interface{}, +) (bool, error) { + stored, _, err := r.setMetadataKeyIfStamp(ctx, "task_sessions", "agent session", sessionID, key, expectedStamp, value, metadataCASExpected) + return stored, err +} + +// SetTaskMetadataKeyIfStamp replaces one task metadata value only when the +// current value still has expectedStamp. +func (r *Repository) SetTaskMetadataKeyIfStamp( + ctx context.Context, + taskID, key, expectedStamp string, + value interface{}, +) (bool, error) { + stored, _, err := r.setMetadataKeyIfStamp(ctx, "tasks", "task", taskID, key, expectedStamp, value, metadataCASExpected) + return stored, err +} + +// SetTaskMetadataKeyIfDifferentStamp stores a task metadata value unless the +// current value already has newStamp. It is the atomic idempotency operation +// for repeated PR launch-gate callbacks. +func (r *Repository) SetTaskMetadataKeyIfDifferentStamp( + ctx context.Context, + taskID, key, newStamp string, + value interface{}, +) (stored bool, noOp bool, err error) { + return r.setMetadataKeyIfStamp(ctx, "tasks", "task", taskID, key, newStamp, value, metadataCASDifferent) +} + +func (r *Repository) setMetadataKeyIfStamp( + ctx context.Context, + table, entityName, entityID, key, expectedStamp string, + value interface{}, + mode metadataCASMode, +) (bool, bool, error) { + if strings.TrimSpace(expectedStamp) == "" { + return false, false, nil + } + payload, err := json.Marshal(value) + if err != nil { + return false, false, fmt.Errorf("failed to serialize metadata value: %w", err) + } + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return false, false, err + } + defer func() { _ = tx.Rollback() }() + + metadata, err := r.lockMetadataRow(ctx, tx, table, entityName, entityID) + if err != nil { + return false, false, err + } + currentStamp, err := metadataRecordStamp(metadata, key) + if err != nil { + return false, false, err + } + shouldWrite := currentStamp == expectedStamp + if mode == metadataCASDifferent { + shouldWrite = currentStamp != expectedStamp + } + if !shouldWrite { + if err := tx.Commit(); err != nil { + return false, false, err + } + return false, true, nil + } + + result, err := tx.ExecContext(ctx, r.db.Rebind(metadataKeyUpdateQuery(table, r.db.DriverName())), metadataKeyUpdateArgs(r.db.DriverName(), key, string(payload), r.nowUTC(), entityID)...) + if err != nil { + return false, false, err + } + rows, err := result.RowsAffected() + if err != nil { + return false, false, err + } + if rows == 0 { + return false, false, fmt.Errorf("%s not found: %s", entityName, entityID) + } + if err := tx.Commit(); err != nil { + return false, false, err + } + return true, false, nil +} + +func (r *Repository) lockMetadataRow( + ctx context.Context, + tx *sqlx.Tx, + table, entityName, entityID string, +) (string, error) { + query := "SELECT metadata FROM " + table + " WHERE id = ?" + if dialect.IsPostgres(r.db.DriverName()) { + query += " FOR UPDATE" + } + var raw sql.NullString + if err := tx.QueryRowxContext(ctx, r.db.Rebind(query), entityID).Scan(&raw); err != nil { + if err == sql.ErrNoRows { + return "", fmt.Errorf("%s not found: %s", entityName, entityID) + } + return "", err + } + if !raw.Valid { + return "{}", nil + } + return raw.String, nil +} + +func metadataRecordStamp(metadataJSON, key string) (string, error) { + if strings.TrimSpace(metadataJSON) == "" || strings.TrimSpace(metadataJSON) == jsonNull { + return "", nil + } + var metadata map[string]json.RawMessage + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return "", fmt.Errorf("failed to parse metadata: %w", err) + } + raw, ok := metadata[key] + if !ok || string(raw) == "null" { + return "", nil + } + var value struct { + Message string `json:"message"` + OccurredAt time.Time `json:"occurred_at"` + StampValue string `json:"stamp"` + } + if err := json.Unmarshal(raw, &value); err != nil { + return "", fmt.Errorf("failed to parse metadata key %q: %w", key, err) + } + if stamp := strings.TrimSpace(value.StampValue); stamp != "" { + return stamp, nil + } + if value.Message == "" { + return "", nil + } + return value.OccurredAt.UTC().Format(time.RFC3339Nano) + ":" + value.Message, nil +} + +func metadataKeyUpdateQuery(table, driver string) string { + if dialect.IsPostgres(driver) { + return "UPDATE " + table + " SET metadata = jsonb_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END, ARRAY[?]::text[], ?::jsonb, true)::text, updated_at = ? WHERE id = ?" + } + return "UPDATE " + table + " SET metadata = json_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?, json(?)), updated_at = ? WHERE id = ?" +} + +func metadataKeyUpdateArgs(driver, key, payload string, updatedAt time.Time, entityID string) []interface{} { + path := key + if !dialect.IsPostgres(driver) { + path = jsonPath(key) + } + return []interface{}{path, payload, updatedAt, entityID} +} diff --git a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go index 04520c7397..d748f2322a 100644 --- a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go +++ b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go @@ -752,9 +752,9 @@ func TestPostgresClearRecoveredAgentErrors(t *testing.T) { t.Fatalf("CreateTaskSession %s: %v", sessionID, err) } } - // Seeded with plain SQL on purpose: SetSessionMetadataKey is SQLite-only - // (json_set/json()), so using it here would fail on the seed rather than - // exercise the migration under test. + // Seeded with plain SQL on purpose so this migration fixture keeps its exact + // metadata shape. SetSessionMetadataKey's dialect-aware JSON update is + // covered by the dedicated PostgreSQL launch-error test. lastAgentError := `{"last_agent_error":{"message":"agent crashed","occurred_at":"` + occurredAt.Format(time.RFC3339Nano) + `"}}` for _, sessionID := range []string{"pg-recovered", "pg-current"} { diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity.go b/apps/backend/internal/task/repository/sqlite/repository_entity.go index e9221bdc2b..d1e7011946 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity.go @@ -89,6 +89,29 @@ func (r *Repository) UpdateRepository(ctx context.Context, repository *models.Re return r.updateRepository(ctx, r.db, repository) } +// UpdateRepositoryDefaultBranch updates only the default branch while the +// caller's previously read value is still current. This protects unrelated +// repository fields from stale recovery writes. +func (r *Repository) UpdateRepositoryDefaultBranch(ctx context.Context, repositoryID, expectedBranch, branch string) error { + updatedAt := time.Now().UTC() + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE repositories + SET default_branch = ?, updated_at = ? + WHERE id = ? AND default_branch = ? AND deleted_at IS NULL + `), branch, updatedAt, repositoryID, expectedBranch) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("repository default branch changed or repository not found: %s", repositoryID) + } + return nil +} + func (r *Repository) updateRepository(ctx context.Context, exec sqlx.ExtContext, repository *models.Repository) error { repository.UpdatedAt = time.Now().UTC() diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity_test.go b/apps/backend/internal/task/repository/sqlite/repository_entity_test.go index 2ca03b3e98..1dae6cf2a5 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity_test.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity_test.go @@ -454,6 +454,31 @@ func TestRepositoryProviderHost_RoundTrip(t *testing.T) { } } +func TestUpdateRepositoryDefaultBranchUsesExpectedValue(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + seedWorkspace(t, repo, "ws-default-branch-cas") + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-default-branch-cas", WorkspaceID: "ws-default-branch-cas", Name: "default-branch", DefaultBranch: "main", + }); err != nil { + t.Fatalf("create repository: %v", err) + } + + if err := repo.UpdateRepositoryDefaultBranch(ctx, "repo-default-branch-cas", "main", "trunk"); err != nil { + t.Fatalf("update default branch: %v", err) + } + if err := repo.UpdateRepositoryDefaultBranch(ctx, "repo-default-branch-cas", "main", "develop"); err == nil { + t.Fatal("stale expected branch was accepted") + } + got, err := repo.GetRepository(ctx, "repo-default-branch-cas") + if err != nil { + t.Fatalf("get repository: %v", err) + } + if got.DefaultBranch != "trunk" { + t.Fatalf("default branch = %q, want trunk", got.DefaultBranch) + } +} + func TestGetRepositoryByProviderInfoSeparatesGitLabHosts(t *testing.T) { repo := newRepoForEntityTests(t) ctx := context.Background() diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index cf64b76737..098a5f28c9 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -1418,22 +1418,21 @@ func (r *Repository) updateSessionMetadataJSON( } // SetSessionMetadataKey atomically sets a single key in the session's metadata -// using SQLite's json_set. Unlike UpdateSessionMetadata (which does a full -// replacement), this preserves all other metadata keys. +// using the active database dialect. Unlike UpdateSessionMetadata (which does +// a full replacement), this preserves all other metadata keys. func (r *Repository) SetSessionMetadataKey(ctx context.Context, sessionID, key string, value interface{}) error { valueJSON, err := json.Marshal(value) if err != nil { return fmt.Errorf("failed to serialize metadata value: %w", err) } - now := time.Now().UTC() - path := "$." + key - result, err := r.db.ExecContext(ctx, r.db.Rebind(` - UPDATE task_sessions SET metadata = json_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?, json(?)), updated_at = ? WHERE id = ? - `), path, string(valueJSON), now, sessionID) + result, err := r.db.ExecContext(ctx, r.db.Rebind(metadataKeyUpdateQuery("task_sessions", r.db.DriverName())), metadataKeyUpdateArgs(r.db.DriverName(), key, string(valueJSON), r.nowUTC(), sessionID)...) + if err != nil { + return err + } + rows, err := result.RowsAffected() if err != nil { return err } - rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("agent session not found: %s", sessionID) } @@ -1645,6 +1644,48 @@ func (r *Repository) RemoveSessionMetadataKeyIfState( return rows > 0, nil } +// RemoveSessionMetadataKeyIfStamp removes a metadata object only when its +// nested stamp still equals expectedStamp. The comparison and key removal are +// one statement so a successful recovery cannot erase a newer session error. +func (r *Repository) RemoveSessionMetadataKeyIfStamp( + ctx context.Context, + sessionID, key, expectedStamp string, +) (bool, error) { + if strings.TrimSpace(expectedStamp) == "" { + return false, nil + } + now := time.Now().UTC() + driver := r.db.DriverName() + var query string + var args []interface{} + if dialect.IsPostgres(driver) { + query = ` + UPDATE task_sessions + SET metadata = (CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END #- ARRAY[?]::text[])::text, + updated_at = ? + WHERE id = ? + AND jsonb_extract_path_text(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END, ?, 'stamp') = ? + ` + args = []interface{}{key, now, sessionID, key, expectedStamp} + } else { + path := "$" + "." + key + query = ` + UPDATE task_sessions + SET metadata = json_remove(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?), + updated_at = ? + WHERE id = ? + AND json_extract(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?) = ? + ` + args = []interface{}{path, now, sessionID, path + ".stamp", expectedStamp} + } + result, err := r.db.ExecContext(ctx, r.db.Rebind(query), args...) + if err != nil { + return false, err + } + rows, err := result.RowsAffected() + return rows > 0, err +} + func setSessionMetadataKeyIfAbsentQuery(driver string) string { if dialect.IsPostgres(driver) { return ` diff --git a/apps/backend/internal/task/repository/sqlite/session_launch_error_cas_test.go b/apps/backend/internal/task/repository/sqlite/session_launch_error_cas_test.go new file mode 100644 index 0000000000..f1233e6f2e --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/session_launch_error_cas_test.go @@ -0,0 +1,90 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestSetSessionMetadataKeyIfStampIsAtomicAndPreservesOtherKeys(t *testing.T) { + repo := newRepoForSessionTests(t) + seedForMsgTest(t, repo, "session-cas-write-task", "session-cas-write", "turn-cas-write") + ctx := context.Background() + if err := repo.SetSessionMetadataKey(ctx, "session-cas-write", "other_key", "keep me"); err != nil { + t.Fatalf("seed session metadata: %v", err) + } + if err := repo.SetSessionMetadataKey(ctx, "session-cas-write", models.SessionMetaKeyLastAgentError, models.LastAgentError{ + Message: "old error", + OccurredAt: time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC), + StampValue: "old-stamp", + }); err != nil { + t.Fatalf("seed session error: %v", err) + } + + stored, err := repo.SetSessionMetadataKeyIfStamp(ctx, "session-cas-write", models.SessionMetaKeyLastAgentError, "old-stamp", models.LastAgentError{ + Message: "new error", + OccurredAt: time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC), + StampValue: "new-stamp", + }) + if err != nil { + t.Fatalf("stamped session write: %v", err) + } + if !stored { + t.Fatal("current session error was not replaced") + } + + stored, err = repo.SetSessionMetadataKeyIfStamp(ctx, "session-cas-write", models.SessionMetaKeyLastAgentError, "old-stamp", models.LastAgentError{ + Message: "stale error", + OccurredAt: time.Date(2026, 8, 20, 11, 0, 0, 0, time.UTC), + StampValue: "stale-stamp", + }) + if err != nil { + t.Fatalf("stale stamped session write: %v", err) + } + if stored { + t.Fatal("stale session write replaced the newer error") + } + + session, err := repo.GetTaskSession(ctx, "session-cas-write") + if err != nil { + t.Fatalf("load session: %v", err) + } + lastError, ok := models.LoadLastAgentError(session.Metadata) + if !ok || lastError.Stamp() != "new-stamp" { + t.Fatalf("session error = %#v, want new-stamp", lastError) + } + if session.Metadata["other_key"] != "keep me" { + t.Fatalf("other session metadata = %#v, want preserved value", session.Metadata["other_key"]) + } +} + +func TestRemoveSessionMetadataKeyIfStampDoesNotEraseNewerError(t *testing.T) { + repo := newRepoForSessionTests(t) + seedForMsgTest(t, repo, "task-session-error-cas", "session-error-cas", "turn-error-cas") + ctx := context.Background() + if err := repo.SetSessionMetadataKey(ctx, "session-error-cas", "last_agent_error", map[string]interface{}{ + "message": "new error", + "stamp": "new-stamp", + "occurred_at": "2026-08-19T00:00:00Z", + }); err != nil { + t.Fatalf("seed session error: %v", err) + } + + removed, err := repo.RemoveSessionMetadataKeyIfStamp(ctx, "session-error-cas", "last_agent_error", "old-stamp") + if err != nil { + t.Fatalf("RemoveSessionMetadataKeyIfStamp: %v", err) + } + if removed { + t.Fatal("stale stamp removed a newer session error") + } + + removed, err = repo.RemoveSessionMetadataKeyIfStamp(ctx, "session-error-cas", "last_agent_error", "new-stamp") + if err != nil { + t.Fatalf("RemoveSessionMetadataKeyIfStamp with current stamp: %v", err) + } + if !removed { + t.Fatal("current stamp did not remove the session error") + } +} diff --git a/apps/backend/internal/task/repository/sqlite/session_launch_error_postgres_test.go b/apps/backend/internal/task/repository/sqlite/session_launch_error_postgres_test.go new file mode 100644 index 0000000000..b29ec3c388 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/session_launch_error_postgres_test.go @@ -0,0 +1,63 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +func TestPostgresSessionMetadataLaunchErrorCASUsesJSONB(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) + } + now := time.Now().UTC() + if _, err := db.Exec(db.Rebind(` + INSERT INTO tasks (id, title, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), "task-pg-session-launch-error", "Session launch error", `{"other_key":"keep me"}`, now, now); err != nil { + t.Fatalf("seed task: %v", err) + } + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_sessions (id, task_id, metadata, started_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), "session-pg-launch-error", "task-pg-session-launch-error", `{"other_key":"keep me"}`, now, now); err != nil { + t.Fatalf("seed session: %v", err) + } + + ctx := context.Background() + if err := repo.SetSessionMetadataKey(ctx, "session-pg-launch-error", models.SessionMetaKeyLastAgentError, models.LastAgentError{ + Message: "old error", + OccurredAt: now, + StampValue: "old-stamp", + }); err != nil { + t.Fatalf("set session metadata with postgres JSONB: %v", err) + } + stored, err := repo.SetSessionMetadataKeyIfStamp(ctx, "session-pg-launch-error", models.SessionMetaKeyLastAgentError, "old-stamp", models.LastAgentError{ + Message: "new error", + OccurredAt: now.Add(time.Minute), + StampValue: "new-stamp", + }) + if err != nil { + t.Fatalf("stamped session metadata with postgres JSONB: %v", err) + } + if !stored { + t.Fatal("postgres stamped session metadata write did not land") + } + + session, err := repo.GetTaskSession(ctx, "session-pg-launch-error") + if err != nil { + t.Fatalf("load postgres session: %v", err) + } + lastError, ok := models.LoadLastAgentError(session.Metadata) + if !ok || lastError.Stamp() != "new-stamp" { + t.Fatalf("postgres session error = %#v, want new-stamp", lastError) + } + if session.Metadata["other_key"] != "keep me" { + t.Fatalf("session-level other_key = %#v, want preserved value", session.Metadata["other_key"]) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 538c84694d..ca3ba7c64e 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -818,6 +818,43 @@ func (r *Repository) RemoveTaskMetadataKey(ctx context.Context, taskID, key stri return r.removeTaskMetadataKeyWithExecutor(ctx, r.db, taskID, key) } +// RemoveTaskMetadataKeyIfStamp removes one metadata object only when its +// nested stamp still equals expectedStamp. The comparison and key removal are +// one statement so a successful launch cannot erase a newer failure. +func (r *Repository) RemoveTaskMetadataKeyIfStamp( + ctx context.Context, + taskID, key, expectedStamp string, +) (bool, error) { + if strings.TrimSpace(expectedStamp) == "" { + return false, nil + } + var query string + var args []interface{} + if dialect.IsPostgres(r.db.DriverName()) { + query = ` + UPDATE tasks + SET metadata = (CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END #- ARRAY[?]::text[])::text, updated_at = ? + WHERE id = ? AND jsonb_extract_path_text(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END, ?, 'stamp') = ? + ` + args = []interface{}{key, time.Now().UTC(), taskID, key, expectedStamp} + } else { + path := jsonPath(key) + stampPath := path + ".stamp" + query = ` + UPDATE tasks + SET metadata = json_remove(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?), updated_at = ? + WHERE id = ? AND json_extract(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?) = ? + ` + args = []interface{}{path, time.Now().UTC(), taskID, stampPath, expectedStamp} + } + result, err := r.db.ExecContext(ctx, r.db.Rebind(query), args...) + if err != nil { + return false, err + } + rows, err := result.RowsAffected() + return rows > 0, err +} + func (r *Repository) removeTaskMetadataKeyWithExecutor( ctx context.Context, exec taskSessionExecutor, diff --git a/apps/backend/internal/task/repository/sqlite/task_launch_error_cas_test.go b/apps/backend/internal/task/repository/sqlite/task_launch_error_cas_test.go new file mode 100644 index 0000000000..515bd24b1f --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/task_launch_error_cas_test.go @@ -0,0 +1,96 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestSetTaskMetadataKeyIfStampAndDifferentStampAreAtomic(t *testing.T) { + repo := newRepoForMetadataCASTests(t) + seedMetadataCASTask(t, repo, map[string]interface{}{ + "last_launch_error": models.TaskLaunchError{ + Message: "old error", + OccurredAt: time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC), + StampValue: "old-stamp", + }, + "other_key": "keep me", + }) + ctx := context.Background() + + stored, err := repo.SetTaskMetadataKeyIfStamp(ctx, casTaskID, models.MetaKeyLastLaunchError, "old-stamp", models.TaskLaunchError{ + Message: "new error", + OccurredAt: time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC), + StampValue: "new-stamp", + }) + if err != nil { + t.Fatalf("stamped task write: %v", err) + } + if !stored { + t.Fatal("current task error was not replaced") + } + + var noOp bool + stored, noOp, err = repo.SetTaskMetadataKeyIfDifferentStamp(ctx, casTaskID, models.MetaKeyLastLaunchError, "new-stamp", models.TaskLaunchError{ + Message: "duplicate error", + OccurredAt: time.Date(2026, 8, 20, 11, 0, 0, 0, time.UTC), + StampValue: "new-stamp", + }) + if err != nil { + t.Fatalf("same-stamp task write: %v", err) + } + if stored { + t.Fatal("same-stamp task write changed the first occurrence") + } + if !noOp { + t.Fatal("same-stamp task write did not report a confirmed no-op") + } + + task, err := repo.GetTask(ctx, casTaskID) + if err != nil { + t.Fatalf("load task: %v", err) + } + launchError, ok := models.LoadTaskLaunchError(task.Metadata) + if !ok || launchError.Stamp() != "new-stamp" || !launchError.OccurredAt.Equal(time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC)) { + t.Fatalf("task error = %#v, want first new-stamp occurrence", launchError) + } + if task.Metadata["other_key"] != "keep me" { + t.Fatalf("other task metadata = %#v, want preserved value", task.Metadata["other_key"]) + } +} + +func TestRemoveTaskMetadataKeyIfStampDoesNotEraseNewerError(t *testing.T) { + repo := newRepoForMetadataCASTests(t) + seedMetadataCASTask(t, repo, map[string]interface{}{ + "last_launch_error": map[string]interface{}{ + "message": "new error", + "stamp": "new-stamp", + "occurred_at": "2026-08-19T00:00:00Z", + }, + }) + + removed, err := repo.RemoveTaskMetadataKeyIfStamp( + context.Background(), casTaskID, "last_launch_error", "old-stamp", + ) + if err != nil { + t.Fatalf("RemoveTaskMetadataKeyIfStamp: %v", err) + } + if removed { + t.Fatal("stale stamp removed a newer launch error") + } + if _, ok := metadataValue(t, repo, "last_launch_error"); !ok { + t.Fatal("newer launch error disappeared after stale clear") + } + + removed, err = repo.RemoveTaskMetadataKeyIfStamp( + context.Background(), casTaskID, "last_launch_error", "new-stamp", + ) + if err != nil { + t.Fatalf("RemoveTaskMetadataKeyIfStamp with current stamp: %v", err) + } + if !removed { + t.Fatal("current stamp did not remove the launch error") + } +} diff --git a/apps/backend/internal/task/repository/sqlite/task_launch_error_postgres_test.go b/apps/backend/internal/task/repository/sqlite/task_launch_error_postgres_test.go new file mode 100644 index 0000000000..430c6965fc --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/task_launch_error_postgres_test.go @@ -0,0 +1,39 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/testutil" +) + +func TestPostgresRemoveTaskMetadataKeyIfStampUsesJSONB(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) + } + now := time.Now().UTC() + if _, err := db.Exec(db.Rebind(` + INSERT INTO tasks (id, title, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), "task-pg-launch-error-cas", "Launch error CAS", `{"last_launch_error":{"stamp":"new-stamp","message":"boom"}}`, now, now); err != nil { + t.Fatalf("seed task: %v", err) + } + + removed, err := repo.RemoveTaskMetadataKeyIfStamp(context.Background(), "task-pg-launch-error-cas", "last_launch_error", "old-stamp") + if err != nil { + t.Fatalf("stale RemoveTaskMetadataKeyIfStamp: %v", err) + } + if removed { + t.Fatal("stale stamp removed the newer launch error") + } + removed, err = repo.RemoveTaskMetadataKeyIfStamp(context.Background(), "task-pg-launch-error-cas", "last_launch_error", "new-stamp") + if err != nil { + t.Fatalf("current RemoveTaskMetadataKeyIfStamp: %v", err) + } + if !removed { + t.Fatal("current stamp did not remove the launch error") + } +} diff --git a/apps/backend/internal/task/service/service_status_summary_rebuild.go b/apps/backend/internal/task/service/service_status_summary_rebuild.go index ad88c97c6e..d13661196e 100644 --- a/apps/backend/internal/task/service/service_status_summary_rebuild.go +++ b/apps/backend/internal/task/service/service_status_summary_rebuild.go @@ -147,6 +147,7 @@ func (s *Service) rebuildMissingSummaries( for _, task := range missing { activityAt := activityAtByTask[task.ID] s.rebuildMissingSummary(ctx, task, summaries, s.rebuildInput( + taskLaunchErrorSummary(task), sessionsByTask[task.ID], pendingBySession, gitBySession, @@ -515,6 +516,7 @@ func (s *Service) loadSummaryGit( } func (s *Service) rebuildInput( + taskError *statussummary.ActiveErrorSummary, sessions []*models.TaskSession, pendingBySession map[string]models.TaskPendingAction, gitBySession map[string]*models.GitSnapshot, @@ -528,6 +530,7 @@ func (s *Service) rebuildInput( ) statussummary.RebuildInput { input := statussummary.RebuildInput{ Sessions: make([]statussummary.RebuildSession, 0, len(sessions)), + TaskError: taskError, PendingActions: make(map[string]string), ActivityObserved: s.foregroundActivity != nil, LastActivityAt: nil, @@ -560,10 +563,13 @@ func (s *Service) rebuildInput( var activeError *statussummary.ActiveErrorSummary if lastError, ok := models.LoadLastAgentError(session.Metadata); ok && !lastError.IsDismissed() { activeError = &statussummary.ActiveErrorSummary{ - SessionID: session.ID, - Stamp: lastError.Stamp(), - OccurredAt: lastError.OccurredAt, - Preview: lastError.Message, + SessionID: session.ID, + TaskRepositoryID: lastError.TaskRepositoryID, + Stamp: lastError.Stamp(), + OccurredAt: lastError.OccurredAt, + Preview: lastError.Message, + Category: lastError.Code, + RecoveryActions: lastError.RecoveryActions, } } input.Sessions = append(input.Sessions, statussummary.RebuildSession{ @@ -587,6 +593,24 @@ func (s *Service) rebuildInput( return input } +func taskLaunchErrorSummary(task *models.Task) *statussummary.ActiveErrorSummary { + if task == nil { + return nil + } + errorValue, ok := models.LoadTaskLaunchError(task.Metadata) + if !ok { + return nil + } + return &statussummary.ActiveErrorSummary{ + TaskRepositoryID: errorValue.TaskRepositoryID, + Stamp: errorValue.Stamp(), + OccurredAt: errorValue.OccurredAt, + Preview: errorValue.Message, + Category: errorValue.Code, + RecoveryActions: errorValue.RecoveryActions, + } +} + func snapshotRepositoryKey(snapshot *models.GitSnapshot, fallback string) string { if snapshot != nil && snapshot.Metadata != nil { if repository, ok := snapshot.Metadata["repository_name"].(string); ok && strings.TrimSpace(repository) != "" { diff --git a/apps/backend/internal/task/service/service_status_summary_rebuild_test.go b/apps/backend/internal/task/service/service_status_summary_rebuild_test.go index 5ff9979098..6821b612a4 100644 --- a/apps/backend/internal/task/service/service_status_summary_rebuild_test.go +++ b/apps/backend/internal/task/service/service_status_summary_rebuild_test.go @@ -241,7 +241,19 @@ func TestReconcileTaskStatusSummariesRepairsMissingTaskOnce(t *testing.T) { prReader := &statusSummaryRebuildPRReader{} svc.SetTaskStatusSummaryPRReader(prReader) svc.SetQueuedPromptCounter(statusSummaryQueuedPromptCounter{counts: map[string]int{"task-1": 2}}) - task := &models.Task{ID: "task-1", WorkspaceID: "ws-1"} + task := &models.Task{ + ID: "task-1", WorkspaceID: "ws-1", + Metadata: map[string]interface{}{ + models.MetaKeyLastLaunchError: models.TaskLaunchError{ + Message: "The linked pull request is already closed or merged.", + OccurredAt: now.Add(time.Minute), + Code: models.LaunchErrorCategoryPRAlreadyClosed, + RecoveryActions: []string{models.RecoveryActionMarkReviewDone}, + TaskRepositoryID: "task-repository-1", + StampValue: "task-error-1", + }, + }, + } sessions := map[string][]*models.TaskSession{task.ID: {session}} pending := map[string]models.TaskPendingAction{session.ID: models.TaskPendingActionPermission} @@ -264,9 +276,13 @@ func TestReconcileTaskStatusSummariesRepairsMissingTaskOnce(t *testing.T) { if summary.ForegroundActivity != "generating" || summary.ActiveSubagentCount != 3 { t.Fatalf("activity summary = %+v", summary) } - if summary.ActiveError == nil || summary.ActiveError.Preview != "agent failed to complete the turn" { + if summary.ActiveError == nil || summary.ActiveError.Preview != "The linked pull request is already closed or merged." { t.Fatalf("active error = %+v", summary.ActiveError) } + if summary.ActiveError.TaskRepositoryID != "task-repository-1" || + summary.ActiveError.Category != models.LaunchErrorCategoryPRAlreadyClosed { + t.Fatalf("task-owned active error = %+v", summary.ActiveError) + } if summary.Git == nil || summary.Git.Additions != 5 || summary.Git.Deletions != 2 || summary.Git.ChangedFiles != 2 { t.Fatalf("Git summary = %+v", summary.Git) } diff --git a/apps/backend/internal/task/service/service_workflow.go b/apps/backend/internal/task/service/service_workflow.go index 010391d092..30fd6947e1 100644 --- a/apps/backend/internal/task/service/service_workflow.go +++ b/apps/backend/internal/task/service/service_workflow.go @@ -393,6 +393,10 @@ type MoveTaskResult struct { // MoveTaskOptions controls non-default move behavior for trusted callers. type MoveTaskOptions struct { AllowActivePrimarySession bool + // AllowFailedToCompletedRecovery permits the trusted launch-recovery + // action to complete a failed task when it moves into a validated terminal + // workflow step. Ordinary task moves preserve failed and cancelled states. + AllowFailedToCompletedRecovery bool // PreserveDeferredLaunch keeps the deferred launch intent when an internal // queue promotion changes workflow steps. Manual moves still clear it. PreserveDeferredLaunch bool @@ -490,7 +494,7 @@ func (s *Service) MoveTaskWithOptions( stepChanged := oldStepID != workflowStepID stateAfterAdmission := *task if stepChanged { - if err := s.syncTaskStateForWorkflowMove(ctx, &stateAfterAdmission, oldStepID, workflowStepID); err != nil { + if err := s.syncTaskStateForWorkflowMove(ctx, &stateAfterAdmission, oldStepID, workflowStepID, opts); err != nil { return nil, fmt.Errorf("failed to sync task state for workflow move: %w", err) } } @@ -535,6 +539,15 @@ func (s *Service) MoveTaskWithOptions( if stepChanged { admittedState = &stateAfterAdmission.State } + if !stepChanged && opts.AllowFailedToCompletedRecovery && task.State == v1.TaskStateFailed { + terminal, err := s.terminalWorkflowStep(ctx, workflowStepID) + if err != nil { + return nil, fmt.Errorf("failed to validate recovery target: %w", err) + } + if terminal { + task.State = v1.TaskStateCompleted + } + } // manual_move only applies when no outer caller already declared a // trigger — an mcp_move set by the MCP handler must survive this inner @@ -624,13 +637,15 @@ func (s *Service) terminalWorkflowStep(ctx context.Context, workflowStepID strin return wfmodels.IsTerminalStep(step, nextStep), nil } -func (s *Service) syncTaskStateForWorkflowMove(ctx context.Context, task *models.Task, oldStepID, newStepID string) error { +func (s *Service) syncTaskStateForWorkflowMove(ctx context.Context, task *models.Task, oldStepID, newStepID string, opts MoveTaskOptions) error { newTerminal, err := s.terminalWorkflowStep(ctx, newStepID) if err != nil { return err } if newTerminal { - if !models.IsTerminalTaskState(task.State) { + if task.State == v1.TaskStateFailed && opts.AllowFailedToCompletedRecovery { + task.State = v1.TaskStateCompleted + } else if !models.IsTerminalTaskState(task.State) { task.State = v1.TaskStateCompleted } return nil diff --git a/apps/backend/internal/task/service/service_workflow_test.go b/apps/backend/internal/task/service/service_workflow_test.go index 714132ad1b..e4228ed1f7 100644 --- a/apps/backend/internal/task/service/service_workflow_test.go +++ b/apps/backend/internal/task/service/service_workflow_test.go @@ -261,6 +261,64 @@ func TestService_MoveTaskToTerminalStepPreservesTerminalFailureStates(t *testing } } +func TestService_MoveTaskRecoveryCompletesFailedTaskAtTerminalStep(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + getter := svc.workflowStepGetter.(*fakeWorkflowStepGetter) + getter.steps["step-done"] = &wfmodels.WorkflowStep{ + ID: "step-done", WorkflowID: "wf-source", Name: "Done", Position: 2, + } + createMoveTask(t, ctx, repo, "task-recovery", "wf-source", "step-source", nil) + task, err := repo.GetTask(ctx, "task-recovery") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + task.State = v1.TaskStateFailed + must(t, repo.UpdateTask(ctx, task)) + + moved, err := svc.MoveTaskWithOptions(ctx, task.ID, "wf-source", "step-done", 0, MoveTaskOptions{ + AllowFailedToCompletedRecovery: true, + }) + if err != nil { + t.Fatalf("MoveTaskWithOptions: %v", err) + } + if moved.Task.State != v1.TaskStateCompleted { + t.Fatalf("recovered task state = %q, want COMPLETED", moved.Task.State) + } + + stored, err := repo.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("GetTask after recovery: %v", err) + } + if stored.State != v1.TaskStateCompleted { + t.Fatalf("persisted recovered task state = %q, want COMPLETED", stored.State) + } +} + +func TestService_MoveTaskRecoveryIsIdempotentAtTerminalStep(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + getter := svc.workflowStepGetter.(*fakeWorkflowStepGetter) + getter.steps["step-done"] = &wfmodels.WorkflowStep{ + ID: "step-done", WorkflowID: "wf-source", Name: "Done", Position: 2, + } + createMoveTask(t, ctx, repo, "task-recovery-idempotent", "wf-source", "step-done", nil) + + moved, err := svc.MoveTaskWithOptions(ctx, "task-recovery-idempotent", "wf-source", "step-done", 0, MoveTaskOptions{ + AllowFailedToCompletedRecovery: true, + }) + if err != nil { + t.Fatalf("MoveTaskWithOptions on target step: %v", err) + } + if moved.Task.WorkflowStepID != "step-done" { + t.Fatalf("idempotent recovery moved task to %q", moved.Task.WorkflowStepID) + } +} + func TestService_MoveTaskFailsWhenTerminalStatusLookupFails(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() diff --git a/apps/backend/internal/task/statussummary/model.go b/apps/backend/internal/task/statussummary/model.go index ba2ffbd063..b97da36026 100644 --- a/apps/backend/internal/task/statussummary/model.go +++ b/apps/backend/internal/task/statussummary/model.go @@ -7,19 +7,24 @@ import ( "encoding/json" "fmt" "reflect" + "slices" "time" "unicode/utf8" + + "github.com/kandev/kandev/internal/task/models" ) const ( // MaxActiveErrorPreviewBytes keeps an error decoration safe to send with // every task row without turning it into a message-stream transport. - MaxActiveErrorPreviewBytes = 512 - maxSessionIDBytes = 256 - maxPendingActionBytes = 128 - maxActiveErrorStampBytes = 64 - maxPullRequestStateBytes = 64 - maxPullRequestURLBytes = 2048 + MaxActiveErrorPreviewBytes = 512 + maxSessionIDBytes = 256 + maxTaskRepositoryIDBytes = 256 + maxPendingActionBytes = 128 + maxActiveErrorStampBytes = 64 + maxActiveErrorCategoryBytes = 64 + maxPullRequestStateBytes = 64 + maxPullRequestURLBytes = 2048 ) // TaskStatusSummary is the complete replacement value delivered to task-list @@ -49,10 +54,13 @@ type PrimarySessionSummary struct { } type ActiveErrorSummary struct { - SessionID string `json:"session_id"` - Stamp string `json:"stamp"` - OccurredAt time.Time `json:"occurred_at"` - Preview string `json:"preview"` + SessionID string `json:"session_id,omitempty"` + TaskRepositoryID string `json:"task_repository_id,omitempty"` + Stamp string `json:"stamp"` + OccurredAt time.Time `json:"occurred_at"` + Preview string `json:"preview"` + Category string `json:"category,omitempty"` + RecoveryActions []string `json:"recovery_actions,omitempty"` } type GitSummary struct { @@ -134,14 +142,22 @@ func validateActiveError(activeError *ActiveErrorSummary) error { limit int }{ {"active error session id", activeError.SessionID, maxSessionIDBytes}, - {"active error stamp", activeError.Stamp, maxSessionIDBytes}, + {"active error task repository id", activeError.TaskRepositoryID, maxTaskRepositoryIDBytes}, + {"active error stamp", activeError.Stamp, maxActiveErrorStampBytes}, {"active error preview", activeError.Preview, MaxActiveErrorPreviewBytes}, + {"active error category", activeError.Category, maxActiveErrorCategoryBytes}, } for _, field := range fields { if err := validateUTF8Bytes(field.name, field.value, field.limit); err != nil { return err } } + if len(activeError.RecoveryActions) > 3 { + return fmt.Errorf("active error has more than three recovery actions") + } + if !slices.Equal(activeError.RecoveryActions, models.NormalizeRecoveryActions(activeError.RecoveryActions)) { + return fmt.Errorf("active error has unknown or duplicate recovery actions") + } return nil } diff --git a/apps/backend/internal/task/statussummary/model_test.go b/apps/backend/internal/task/statussummary/model_test.go index 721f24a04f..4d19554bd8 100644 --- a/apps/backend/internal/task/statussummary/model_test.go +++ b/apps/backend/internal/task/statussummary/model_test.go @@ -2,6 +2,7 @@ package statussummary import ( "encoding/json" + "reflect" "strings" "testing" "time" @@ -135,3 +136,76 @@ func TestTaskStatusSummaryValidateBoundsErrorPreview(t *testing.T) { t.Fatal("invalid UTF-8 preview should be rejected") } } + +func TestTaskStatusSummaryProjectsTaskOwnedErrorFields(t *testing.T) { + now := time.Date(2026, 8, 19, 20, 0, 0, 0, time.UTC) + got := BuildFromAuthoritative(RebuildInput{ + TaskError: &ActiveErrorSummary{ + TaskRepositoryID: "task-repository-1", + Stamp: "task-error-1", + OccurredAt: now, + Preview: "The linked pull request is already closed or merged.", + Category: "pr_already_closed", + RecoveryActions: []string{ + "mark_review_done", "unknown", "mark_review_done", "retry_default", + }, + }, + Sessions: []RebuildSession{{ + ID: "older-session", + ActiveError: &ActiveErrorSummary{ + SessionID: "older-session", + Stamp: "session-error-1", + OccurredAt: now.Add(-time.Minute), + Preview: "older", + }, + }}, + Now: now, + }) + + if got.ActiveError == nil { + t.Fatal("active error is nil") + } + if got.ActiveError.SessionID != "" || got.ActiveError.TaskRepositoryID != "task-repository-1" { + t.Fatalf("active error identity = %+v", got.ActiveError) + } + if got.ActiveError.Category != "pr_already_closed" { + t.Fatalf("active error category = %q", got.ActiveError.Category) + } + wantActions := []string{"mark_review_done", "retry_default"} + if !reflect.DeepEqual(got.ActiveError.RecoveryActions, wantActions) { + t.Fatalf("active error actions = %#v, want %#v", got.ActiveError.RecoveryActions, wantActions) + } +} + +func TestTaskStatusSummaryValidatesActiveErrorCategoryAndTaskRepositoryBounds(t *testing.T) { + tooLargeCategory := TaskStatusSummary{ActiveError: &ActiveErrorSummary{ + Category: strings.Repeat("x", 65), + }} + if err := tooLargeCategory.Validate(); err == nil { + t.Fatal("oversized active error category should be rejected") + } + tooLargeRepository := TaskStatusSummary{ActiveError: &ActiveErrorSummary{ + TaskRepositoryID: strings.Repeat("x", 257), + }} + if err := tooLargeRepository.Validate(); err == nil { + t.Fatal("oversized task repository identity should be rejected") + } +} + +func TestBuildFromAuthoritativeUsesStableStampAsErrorTieBreaker(t *testing.T) { + now := time.Date(2026, 8, 19, 20, 0, 0, 0, time.UTC) + got := BuildFromAuthoritative(RebuildInput{ + Sessions: []RebuildSession{ + {ID: "session-a", ActiveError: &ActiveErrorSummary{ + SessionID: "session-a", Stamp: "stamp-a", OccurredAt: now, Preview: "a", + }}, + {ID: "session-b", ActiveError: &ActiveErrorSummary{ + SessionID: "session-b", Stamp: "stamp-b", OccurredAt: now, Preview: "b", + }}, + }, + Now: now, + }) + if got.ActiveError == nil || got.ActiveError.Stamp != "stamp-b" { + t.Fatalf("active error = %+v, want the stable-stamp winner", got.ActiveError) + } +} diff --git a/apps/backend/internal/task/statussummary/projector.go b/apps/backend/internal/task/statussummary/projector.go index 27513f1415..a89a979cb9 100644 --- a/apps/backend/internal/task/statussummary/projector.go +++ b/apps/backend/internal/task/statussummary/projector.go @@ -54,6 +54,17 @@ type SessionObservationSnapshot struct { // observations for one task. type SessionObservationLoader func(context.Context, string) (SessionObservationSnapshot, error) +// TaskLaunchErrorObservation reports whether the task-owned launch-error key +// was observed. A malformed key is not authoritative, so callers can retain +// the last valid summary while an absent key explicitly clears the projection. +type TaskLaunchErrorObservation struct { + Error *ActiveErrorSummary + Observed bool +} + +// TaskLaunchErrorLoader reads the bounded task-owned pre-session error source. +type TaskLaunchErrorLoader func(context.Context, string) (TaskLaunchErrorObservation, error) + // TaskActivityLoader rehydrates the durable maximum activity timestamp for a // task when a projector starts with a legacy or incomplete summary row. type TaskActivityLoader func(context.Context, string) (*time.Time, error) @@ -82,6 +93,7 @@ type ProjectorConfig struct { LoadGitObservations GitObservationLoader LoadPendingActions PendingActionLoader LoadSessionObservations SessionObservationLoader + LoadTaskLaunchError TaskLaunchErrorLoader LoadTaskActivity TaskActivityLoader LoadPullRequests PullRequestLoader // CountQueuedPrompts returns the number of prompts currently en-queued for @@ -104,6 +116,7 @@ type Projector struct { loadGitObservations GitObservationLoader loadPendingActions PendingActionLoader loadSessionObservations SessionObservationLoader + loadTaskLaunchError TaskLaunchErrorLoader loadTaskActivity TaskActivityLoader loadPullRequests PullRequestLoader countQueuedPrompts func(context.Context, string) (int, error) @@ -123,18 +136,20 @@ type taskProjectionLock struct { } type projectionState struct { - workspaceID string - revision uint64 - current *TaskStatusSummary - queuedCount int - lastActivityAt *time.Time - sessions map[string]sessionObservation - pending map[string]string - pendingRequests map[string]pendingRequestIdentity - taskPending string - pendingObserved bool - activityObserved bool - errors map[string]*ActiveErrorSummary + workspaceID string + revision uint64 + current *TaskStatusSummary + queuedCount int + lastActivityAt *time.Time + sessions map[string]sessionObservation + pending map[string]string + pendingRequests map[string]pendingRequestIdentity + taskPending string + pendingObserved bool + activityObserved bool + errors map[string]*ActiveErrorSummary + taskError *ActiveErrorSummary + taskErrorObserved bool // clearedErrorStamps records, per session, the stamp of the last error this // projection cleared, so a durable breadcrumb replayed on a later session // event cannot re-arm an error affordance the agent already recovered from. @@ -191,6 +206,7 @@ func NewProjector(cfg ProjectorConfig) *Projector { loadGitObservations: cfg.LoadGitObservations, loadPendingActions: cfg.LoadPendingActions, loadSessionObservations: cfg.LoadSessionObservations, + loadTaskLaunchError: cfg.LoadTaskLaunchError, loadTaskActivity: cfg.LoadTaskActivity, loadPullRequests: cfg.LoadPullRequests, countQueuedPrompts: cfg.CountQueuedPrompts, @@ -314,6 +330,13 @@ func (p *Projector) handleEvent(ctx context.Context, event *bus.Event) error { } return fmt.Errorf("task status summary %q has no workspace", taskID) } + taskErrorChanged := false + if p.loadTaskLaunchError != nil && isTaskErrorRefreshEvent(event.Type) { + taskErrorChanged, err = p.refreshTaskLaunchError(ctx, taskID, state) + if err != nil { + return err + } + } if event.Type == events.MessageQueueStatusChanged { activityChanged := applyTaskActivityEventLocked(state, event.Type, data) @@ -325,7 +348,7 @@ func (p *Projector) handleEvent(ctx context.Context, event *bus.Event) error { return refreshErr } } - return p.applyQueueStatusEvent(ctx, state, taskID, pendingChanged || activityChanged, event.Type, data) + return p.applyQueueStatusEvent(ctx, state, taskID, pendingChanged || activityChanged || taskErrorChanged, event.Type, data) } refreshPending := p.loadPendingActions != nil && @@ -335,11 +358,11 @@ func (p *Projector) handleEvent(ctx context.Context, event *bus.Event) error { if refreshErr != nil { return refreshErr } - changed := p.applySourceEventLocked(state, event.Type, data) || pendingChanged + changed := p.applySourceEventLocked(state, event.Type, data) || pendingChanged || taskErrorChanged return p.persistPendingRefreshLocked(ctx, taskID, state, changed, event.Type, data) } - changed := p.applySourceEventLocked(state, event.Type, data) + changed := p.applySourceEventLocked(state, event.Type, data) || taskErrorChanged if !changed { return nil } diff --git a/apps/backend/internal/task/statussummary/projector_derive.go b/apps/backend/internal/task/statussummary/projector_derive.go index 996b03de2c..08532a49cb 100644 --- a/apps/backend/internal/task/statussummary/projector_derive.go +++ b/apps/backend/internal/task/statussummary/projector_derive.go @@ -88,21 +88,48 @@ func derivePendingAction(state *projectionState) string { } func deriveActiveError(state *projectionState) *ActiveErrorSummary { - if !state.errorsObserved && state.current != nil && state.current.ActiveError != nil { - copy := *state.current.ActiveError - return © - } var active *ActiveErrorSummary + if !state.errorsObserved && state.current != nil && state.current.ActiveError != nil && + (!state.taskErrorObserved || state.current.ActiveError.SessionID != "") { + copy := *state.current.ActiveError + active = © + } for _, candidate := range state.errors { - if candidate == nil || (active != nil && !candidate.OccurredAt.After(active.OccurredAt)) { + if candidate == nil || !newerActiveError(candidate, active) { continue } copy := *candidate active = © } + if state.taskError != nil && newerActiveError(state.taskError, active) { + copy := *state.taskError + active = © + } return active } +func newerActiveError(candidate, current *ActiveErrorSummary) bool { + if candidate == nil { + return false + } + if current == nil { + return true + } + if candidate.OccurredAt.After(current.OccurredAt) { + return true + } + if !candidate.OccurredAt.Equal(current.OccurredAt) { + return false + } + if candidate.Stamp != current.Stamp { + return candidate.Stamp > current.Stamp + } + if candidate.SessionID != current.SessionID { + return candidate.SessionID > current.SessionID + } + return candidate.TaskRepositoryID > current.TaskRepositoryID +} + func deriveGitSummary(state *projectionState) *GitSummary { if !state.gitObserved && state.gitBaseline != nil { copy := *state.gitBaseline diff --git a/apps/backend/internal/task/statussummary/projector_events.go b/apps/backend/internal/task/statussummary/projector_events.go index 9fd8388d9f..7f0a8dd9b5 100644 --- a/apps/backend/internal/task/statussummary/projector_events.go +++ b/apps/backend/internal/task/statussummary/projector_events.go @@ -295,6 +295,9 @@ func (p *Projector) restorePersistedState(ctx context.Context, taskID string, st if err := p.restoreSessionObservations(ctx, taskID, state); err != nil { return err } + if err := p.restoreTaskLaunchError(ctx, taskID, state); err != nil { + return err + } if err := p.restoreGitObservations(ctx, taskID, state); err != nil { return err } @@ -315,9 +318,14 @@ func applySummaryBaseline(state *projectionState, summary *TaskStatusSummary) { isPrimary: true, } } - if summary.ActiveError != nil && summary.ActiveError.SessionID != "" { + if summary.ActiveError != nil { copy := *summary.ActiveError - state.errors[summary.ActiveError.SessionID] = © + copy.RecoveryActions = normalizeRecoveryActions(copy.RecoveryActions) + if copy.SessionID != "" { + state.errors[copy.SessionID] = © + } else { + state.taskError = © + } } if summary.Git != nil { copy := *summary.Git @@ -362,6 +370,8 @@ func (p *Projector) rebaseProjectionStateFromCurrent( state.pendingObserved = false state.errors = make(map[string]*ActiveErrorSummary) state.errorsObserved = false + state.taskError = nil + state.taskErrorObserved = false state.git = make(map[string]GitSummary) state.gitBaseline = nil state.gitObserved = false @@ -375,6 +385,9 @@ func (p *Projector) rebaseProjectionStateFromCurrent( if err := p.restoreSessionObservations(ctx, taskID, state); err != nil { return err } + if err := p.restoreTaskLaunchError(ctx, taskID, state); err != nil { + return err + } if err := p.restoreGitObservations(ctx, taskID, state); err != nil { return err } @@ -426,6 +439,50 @@ func (p *Projector) restoreSessionObservations( return nil } +func (p *Projector) restoreTaskLaunchError( + ctx context.Context, + taskID string, + state *projectionState, +) error { + if p.loadTaskLaunchError == nil { + return nil + } + observation, err := p.loadTaskLaunchError(ctx, taskID) + if err != nil { + return fmt.Errorf("load task launch error for status summary %q: %w", taskID, err) + } + if !observation.Observed { + return nil + } + state.taskErrorObserved = true + state.taskError = normalizeRebuildError(observation.Error, p.now().UTC()) + return nil +} + +func (p *Projector) refreshTaskLaunchError( + ctx context.Context, + taskID string, + state *projectionState, +) (bool, error) { + observation, err := p.loadTaskLaunchError(ctx, taskID) + if err != nil { + return false, fmt.Errorf("refresh task launch error for status summary %q: %w", taskID, err) + } + if !observation.Observed { + return false, nil + } + next := normalizeRebuildError(observation.Error, p.now().UTC()) + if errorEqual(state.taskError, next) && state.taskErrorObserved { + if state.current == nil && next != nil { + return true, nil + } + return false, nil + } + state.taskErrorObserved = true + state.taskError = next + return true, nil +} + func (p *Projector) restoreGitObservations(ctx context.Context, taskID string, state *projectionState) error { if p.loadGitObservations == nil { return nil diff --git a/apps/backend/internal/task/statussummary/projector_helpers.go b/apps/backend/internal/task/statussummary/projector_helpers.go index 98ce5aa344..a8da4613f3 100644 --- a/apps/backend/internal/task/statussummary/projector_helpers.go +++ b/apps/backend/internal/task/statussummary/projector_helpers.go @@ -4,14 +4,20 @@ import ( "context" "encoding/json" "fmt" + "slices" "strconv" "strings" "time" "unicode/utf8" "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" ) +func isTaskErrorRefreshEvent(eventType string) bool { + return eventType == events.TaskUpdated || eventType == events.TaskStateChanged +} + func isPendingSensitiveEvent(eventType string, data map[string]interface{}) bool { switch eventType { case events.TaskUpdated, @@ -169,10 +175,13 @@ func errorFromMap(now time.Time, sessionID string, data map[string]interface{}) stamp = occurredAt.UTC().Format(time.RFC3339Nano) + ":" + preview } return &ActiveErrorSummary{ - SessionID: truncateString(sessionID, maxSessionIDBytes), - Stamp: truncateString(stamp, maxActiveErrorStampBytes), - OccurredAt: occurredAt.UTC(), - Preview: preview, + SessionID: truncateString(sessionID, maxSessionIDBytes), + TaskRepositoryID: truncateString(stringField(data, "task_repository_id"), maxTaskRepositoryIDBytes), + Stamp: truncateString(stamp, maxActiveErrorStampBytes), + OccurredAt: occurredAt.UTC(), + Preview: preview, + Category: truncateString(firstString(data, "category", "code"), maxActiveErrorCategoryBytes), + RecoveryActions: normalizeRecoveryActions(recoveryActionsValue(data["recovery_actions"])), }, true } @@ -180,8 +189,31 @@ func errorEqual(a, b *ActiveErrorSummary) bool { if a == nil || b == nil { return a == b } - return a.SessionID == b.SessionID && a.Stamp == b.Stamp && - a.OccurredAt.Equal(b.OccurredAt) && a.Preview == b.Preview + return a.SessionID == b.SessionID && a.TaskRepositoryID == b.TaskRepositoryID && + a.Stamp == b.Stamp && a.OccurredAt.Equal(b.OccurredAt) && + a.Preview == b.Preview && a.Category == b.Category && + slices.Equal(a.RecoveryActions, b.RecoveryActions) +} + +func normalizeRecoveryActions(actions []string) []string { + return models.NormalizeRecoveryActions(actions) +} + +func recoveryActionsValue(value interface{}) []string { + switch actions := value.(type) { + case []string: + return append([]string(nil), actions...) + case []interface{}: + out := make([]string, 0, len(actions)) + for _, action := range actions { + if value, ok := action.(string); ok { + out = append(out, value) + } + } + return out + default: + return nil + } } func eventDataMap(data interface{}) (map[string]interface{}, error) { diff --git a/apps/backend/internal/task/statussummary/projector_task_error_test.go b/apps/backend/internal/task/statussummary/projector_task_error_test.go new file mode 100644 index 0000000000..db4f98e004 --- /dev/null +++ b/apps/backend/internal/task/statussummary/projector_task_error_test.go @@ -0,0 +1,125 @@ +package statussummary + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +func taskErrorProjector(t *testing.T, store *projectorTestStore, loader TaskLaunchErrorLoader) *Projector { + t.Helper() + return NewProjector(ProjectorConfig{ + Store: store, + ResolveWorkspace: func(context.Context, string) (string, error) { + return "workspace-1", nil + }, + LoadTaskLaunchError: loader, + Now: func() time.Time { + return time.Date(2026, 8, 19, 20, 0, 0, 0, time.UTC) + }, + }) +} + +func TestProjectorProjectsTaskOwnedLaunchErrorWithoutSession(t *testing.T) { + store := newProjectorTestStore() + projector := taskErrorProjector(t, store, func(context.Context, string) (TaskLaunchErrorObservation, error) { + return TaskLaunchErrorObservation{ + Observed: true, + Error: &ActiveErrorSummary{ + TaskRepositoryID: "task-repository-1", + Stamp: "task-error-1", + OccurredAt: time.Date(2026, 8, 19, 19, 0, 0, 0, time.UTC), + Preview: "The linked pull request is already closed or merged.", + Category: "pr_already_closed", + RecoveryActions: []string{"mark_review_done", "unknown", "mark_review_done"}, + }, + }, nil + }) + + err := projector.HandleEvent(context.Background(), bus.NewEvent(events.TaskUpdated, "test", map[string]interface{}{ + "task_id": "task-1", + "workspace_id": "workspace-1", + })) + if err != nil { + t.Fatalf("project task error: %v", err) + } + got := store.summary("task-1") + if got == nil || got.ActiveError == nil { + t.Fatalf("summary = %+v, want task-owned active error", got) + } + if got.ActiveError.SessionID != "" || got.ActiveError.TaskRepositoryID != "task-repository-1" { + t.Fatalf("active error identity = %+v", got.ActiveError) + } + if len(got.ActiveError.RecoveryActions) != 1 || got.ActiveError.RecoveryActions[0] != "mark_review_done" { + t.Fatalf("active error actions = %#v", got.ActiveError.RecoveryActions) + } +} + +func TestProjectorMalformedTaskLaunchErrorDoesNotEraseStoredError(t *testing.T) { + store := newProjectorTestStore() + store.rows["task-1"] = &StoredTaskStatusSummary{ + TaskID: "task-1", + WorkspaceID: "workspace-1", + Summary: TaskStatusSummary{ + Revision: 1, + ActiveError: &ActiveErrorSummary{ + Stamp: "stored-task-error", + OccurredAt: time.Date(2026, 8, 19, 19, 0, 0, 0, time.UTC), + Preview: "stored task error", + }, + }, + } + projector := taskErrorProjector(t, store, func(context.Context, string) (TaskLaunchErrorObservation, error) { + return TaskLaunchErrorObservation{}, nil + }) + + err := projector.HandleEvent(context.Background(), bus.NewEvent(events.TaskUpdated, "test", map[string]interface{}{ + "task_id": "task-1", + "workspace_id": "workspace-1", + })) + if err != nil { + t.Fatalf("project malformed task error: %v", err) + } + got := store.summary("task-1") + if got == nil || got.ActiveError == nil || got.ActiveError.Stamp != "stored-task-error" { + t.Fatalf("summary = %+v, want stored error preserved", got) + } +} + +func TestProjectorRefreshesTaskLaunchErrorRemoval(t *testing.T) { + store := newProjectorTestStore() + active := true + projector := taskErrorProjector(t, store, func(context.Context, string) (TaskLaunchErrorObservation, error) { + if !active { + return TaskLaunchErrorObservation{Observed: true}, nil + } + return TaskLaunchErrorObservation{ + Observed: true, + Error: &ActiveErrorSummary{ + Stamp: "task-error-1", + OccurredAt: time.Date(2026, 8, 19, 19, 0, 0, 0, time.UTC), + Preview: "task error", + }, + }, nil + }) + + event := func() *bus.Event { + return bus.NewEvent(events.TaskUpdated, "test", map[string]interface{}{ + "task_id": "task-1", + "workspace_id": "workspace-1", + }) + } + if err := projector.HandleEvent(context.Background(), event()); err != nil { + t.Fatalf("project initial task error: %v", err) + } + active = false + if err := projector.HandleEvent(context.Background(), event()); err != nil { + t.Fatalf("project cleared task error: %v", err) + } + if got := store.summary("task-1"); got == nil || got.ActiveError != nil { + t.Fatalf("summary after task error removal = %+v, want no active error", got) + } +} diff --git a/apps/backend/internal/task/statussummary/rebuild.go b/apps/backend/internal/task/statussummary/rebuild.go index 49421b8479..7c683148f6 100644 --- a/apps/backend/internal/task/statussummary/rebuild.go +++ b/apps/backend/internal/task/statussummary/rebuild.go @@ -48,6 +48,7 @@ type PullRequestInput struct { // does not masquerade as an authoritative empty value. type RebuildInput struct { Sessions []RebuildSession + TaskError *ActiveErrorSummary PendingActions map[string]string ActivityObserved bool LastActivityAt *time.Time @@ -97,6 +98,10 @@ func BuildFromAuthoritative(input RebuildInput) TaskStatusSummary { } } } + if taskError := normalizeRebuildError(input.TaskError, input.Now); taskError != nil { + state.taskError = taskError + state.taskErrorObserved = true + } for sessionID, action := range input.PendingActions { if strings.TrimSpace(sessionID) == "" || strings.TrimSpace(action) == "" { continue @@ -130,6 +135,10 @@ func normalizeRebuildError(input *ActiveErrorSummary, now time.Time) *ActiveErro copy.OccurredAt = now.UTC() } copy.Preview = truncateString(copy.Preview, MaxActiveErrorPreviewBytes) + copy.SessionID = truncateString(copy.SessionID, maxSessionIDBytes) + copy.TaskRepositoryID = truncateString(copy.TaskRepositoryID, maxTaskRepositoryIDBytes) + copy.Category = truncateString(copy.Category, maxActiveErrorCategoryBytes) + copy.RecoveryActions = normalizeRecoveryActions(copy.RecoveryActions) if copy.Stamp == "" { copy.Stamp = copy.OccurredAt.UTC().Format(time.RFC3339Nano) + ":" + copy.Preview } diff --git a/apps/backend/internal/worktree/errors.go b/apps/backend/internal/worktree/errors.go index 965b0425b0..307c9ff96c 100644 --- a/apps/backend/internal/worktree/errors.go +++ b/apps/backend/internal/worktree/errors.go @@ -41,6 +41,14 @@ var ( // ErrAuthFailed is returned when git authentication fails (e.g. no credentials in non-interactive mode). ErrAuthFailed = errors.New("git authentication failed") + // ErrRemoteDefaultNetwork is returned when refreshing origin/HEAD fails + // because the remote cannot be reached. + ErrRemoteDefaultNetwork = errors.New("remote default branch network failure") + + // ErrRemoteDefaultUnresolved is returned when origin has no usable default + // branch after the local ref and refresh paths have both been attempted. + ErrRemoteDefaultUnresolved = errors.New("remote default branch unresolved") + // ErrNonFastForward is returned when a fetch/pull is rejected due to non-fast-forward updates. ErrNonFastForward = errors.New("non-fast-forward update rejected") @@ -118,7 +126,9 @@ func isRemoteRefMissingError(err error) bool { msg := strings.ToLower(err.Error()) return strings.Contains(msg, "couldn't find remote ref") || strings.Contains(msg, "does not appear to be a git repository") || - strings.Contains(msg, "no such remote") + strings.Contains(msg, "no such remote") || + strings.Contains(msg, "cannot determine remote head") || + strings.Contains(msg, "remote head refers to nonexistent ref") } // ClassifyGitError wraps a raw git error with a user-friendly sentinel error diff --git a/apps/backend/internal/worktree/errors_test.go b/apps/backend/internal/worktree/errors_test.go index 87b420ac34..21f17406e9 100644 --- a/apps/backend/internal/worktree/errors_test.go +++ b/apps/backend/internal/worktree/errors_test.go @@ -7,6 +7,13 @@ import ( "testing" ) +func TestInvalidBaseBranchRemainsSentinelDetectable(t *testing.T) { + err := fmt.Errorf("prepare failed: %w", ErrInvalidBaseBranch) + if !errors.Is(err, ErrInvalidBaseBranch) { + t.Fatalf("errors.Is(%v, ErrInvalidBaseBranch) = false", err) + } +} + func TestClassifyGitError_BranchCheckedOut(t *testing.T) { output := "fatal: 'feature/pr-branch' is already checked out at '/tmp/worktree-123'" err := ClassifyGitError(output, fmt.Errorf("exit status 128")) diff --git a/apps/backend/internal/worktree/manager_git.go b/apps/backend/internal/worktree/manager_git.go index 7892bb486d..b910c98510 100644 --- a/apps/backend/internal/worktree/manager_git.go +++ b/apps/backend/internal/worktree/manager_git.go @@ -70,6 +70,26 @@ func (m *Manager) branchExists(ctx context.Context, repoPath, branch string) (bo return true, nil } +// runBoundedGitInspect runs a non-interactive local git inspection after +// acquiring the lifecycle throttle. The timeout starts after admission so +// queue wait does not consume the command's inspection budget. +func (m *Manager) runBoundedGitInspect(ctx context.Context, repoPath string, args ...string) (string, error) { + release, err := subproc.AcquireGit(ctx, subproc.GitLifecycle) + if err != nil { + return "", err + } + defer release() + + inspectCtx, cancel := context.WithTimeout(ctx, m.inspectTimeout) + defer cancel() + cmd := m.newNonInteractiveGitCmd(inspectCtx, repoPath, args...) + output, runErr := cmd.CombinedOutput() + if ctxErr := inspectCtx.Err(); ctxErr != nil { + return string(output), fmt.Errorf("git inspection timed out: %w", ctxErr) + } + return string(output), runErr +} + // checkoutBranchExistsAnywhere returns true when the named branch is present // either locally or as origin/. Used by createInTaskDir to decide // whether to treat req.CheckoutBranch as "fetch this existing ref" or as diff --git a/apps/backend/internal/worktree/manager_lifecycle.go b/apps/backend/internal/worktree/manager_lifecycle.go index fbb66656f0..e3ce10c0a9 100644 --- a/apps/backend/internal/worktree/manager_lifecycle.go +++ b/apps/backend/internal/worktree/manager_lifecycle.go @@ -10,6 +10,7 @@ import ( "time" "github.com/google/uuid" + "github.com/kandev/kandev/internal/common/gitref" "github.com/kandev/kandev/internal/system/storage" storageworkspaces "github.com/kandev/kandev/internal/system/storage/workspaces" "go.uber.org/zap" @@ -17,6 +18,81 @@ import ( "github.com/kandev/kandev/internal/worktree/copyfiles" ) +// ResolveRemoteDefaultBranch returns the default branch advertised by the +// origin remote. It reads the local symref first, then refreshes it once with +// non-interactive Git when the local ref is absent. +func (m *Manager) ResolveRemoteDefaultBranch(ctx context.Context, repoPath string) (string, error) { + branch, err := readRemoteDefaultBranch(repoPath) + if err != nil { + return "", err + } + if branch != "" { + return branch, nil + } + + output, runErr := m.runBoundedGitInspect(ctx, repoPath, "remote", "set-head", "origin", "--auto") + if runErr != nil { + return "", classifyRemoteDefaultError(output, runErr) + } + branch, err = readRemoteDefaultBranch(repoPath) + if err != nil { + return "", err + } + if branch == "" { + return "", ErrRemoteDefaultUnresolved + } + return branch, nil +} + +func readRemoteDefaultBranch(repoPath string) (string, error) { + gitDir, err := gitref.ResolveGitDir(repoPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + return "", fmt.Errorf("resolve git directory: %w", err) + } + commonDir := gitref.ResolveCommonGitDir(gitDir) + path := filepath.Join(commonDir, "refs", "remotes", "origin", "HEAD") + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + return "", fmt.Errorf("read origin default branch: %w", err) + } + line := strings.TrimSpace(string(data)) + const prefix = "ref: refs/remotes/origin/" + if !strings.HasPrefix(line, prefix) { + return "", nil + } + branch := strings.TrimPrefix(line, prefix) + if branch == "" || strings.ContainsAny(branch, "\r\n") { + return "", nil + } + return branch, nil +} + +func classifyRemoteDefaultError(output string, runErr error) error { + if errors.Is(runErr, context.Canceled) || errors.Is(runErr, context.DeadlineExceeded) { + return runErr + } + lowerOutput := strings.ToLower(output) + switch { + case containsAuthFailure(lowerOutput): + return fmt.Errorf("%w: %s", ErrAuthFailed, strings.TrimSpace(output)) + case strings.Contains(lowerOutput, "could not resolve host"), + strings.Contains(lowerOutput, "connection timed out"), + strings.Contains(lowerOutput, "network is unreachable"), + strings.Contains(lowerOutput, "could not connect"): + return fmt.Errorf("%w: %s", ErrRemoteDefaultNetwork, strings.TrimSpace(output)) + case isRemoteRefMissingError(fmt.Errorf("%s", output)): + return fmt.Errorf("%w: %s", ErrRemoteDefaultUnresolved, strings.TrimSpace(output)) + default: + return ClassifyGitError(output, runErr) + } +} + // Create creates a new worktree for a session, or returns an existing one. // Each session gets its own worktree for isolation. Checks by SessionID first, // then by WorktreeID if provided (for session resumption). @@ -199,27 +275,54 @@ func (m *Manager) resolveBaseRefWithFallback(ctx context.Context, req *CreateReq } fallback := strings.TrimSpace(req.FallbackBaseBranch) - if fallback == "" || fallback == baseRef { + resolvedFallback := "" + if fallback != "" && fallback != baseRef { + resolvedFallback = m.resolveFallbackRef(ctx, req, fallback) + fallbackExists, fallbackErr := m.branchExists(ctx, req.RepositoryPath, resolvedFallback) + if fallbackErr != nil { + return "", "", "", fmt.Errorf("could not verify fallback base branch %q: %w", resolvedFallback, fallbackErr) + } + if fallbackExists { + return m.finishBaseFallback(req, baseRef, fallback, resolvedFallback) + } + } + + // The persisted repository default can be stale or absent. Resolve the + // live remote default only after the requested branch and stored fallback + // have both failed. + liveFallback, liveErr := m.ResolveRemoteDefaultBranch(ctx, req.RepositoryPath) + if liveErr != nil { + if errors.Is(liveErr, ErrRemoteDefaultUnresolved) { + return "", "", "", fmt.Errorf("%w: %s (fallback %q also not found)", ErrInvalidBaseBranch, baseRef, fallback) + } + return "", "", "", liveErr + } + if liveFallback == baseRef { return "", "", "", fmt.Errorf("%w: %s", ErrInvalidBaseBranch, baseRef) } - // Best-effort fetch of the fallback so it is available locally in - // containerized / shallow-clone environments where the fallback may - // only exist on the remote. pullBaseBranch may resolve the name to a - // remote-tracking ref (e.g. "main" -> "origin/main") which we must use - // for the existence check and downstream git operations. - resolvedFallback := fallback - if req.RemoteSyncHandled { - resolvedFallback = m.preferRefreshedRemoteRef(ctx, req.RepositoryPath, fallback) - } else if req.PullBeforeWorktree { - resolvedFallback = m.pullBaseBranch(ctx, req.RepositoryPath, fallback, nil) - } + resolvedFallback = m.resolveFallbackRef(ctx, req, liveFallback) fallbackExists, fallbackErr := m.branchExists(ctx, req.RepositoryPath, resolvedFallback) if fallbackErr != nil { - return "", "", "", fmt.Errorf("could not verify fallback base branch %q: %w", resolvedFallback, fallbackErr) + return "", "", "", fmt.Errorf("could not verify live fallback base branch %q: %w", resolvedFallback, fallbackErr) } if !fallbackExists { - return "", "", "", fmt.Errorf("%w: %s (fallback %q also not found)", ErrInvalidBaseBranch, baseRef, fallback) + return "", "", "", fmt.Errorf("%w: %s (fallback %q also not found)", ErrInvalidBaseBranch, baseRef, liveFallback) } + fallback = liveFallback + return m.finishBaseFallback(req, baseRef, fallback, resolvedFallback) +} + +func (m *Manager) resolveFallbackRef(ctx context.Context, req *CreateRequest, fallback string) string { + if req.RemoteSyncHandled { + return m.preferRefreshedRemoteRef(ctx, req.RepositoryPath, fallback) + } + if req.PullBeforeWorktree { + return m.pullBaseBranch(ctx, req.RepositoryPath, fallback, nil) + } + return fallback +} + +func (m *Manager) finishBaseFallback(req *CreateRequest, baseRef, fallback, resolvedFallback string) (string, string, string, error) { m.logger.Warn("requested base branch not found, falling back", zap.String("repository_path", req.RepositoryPath), zap.String("requested_branch", baseRef), @@ -227,8 +330,8 @@ func (m *Manager) resolveBaseRefWithFallback(ctx context.Context, req *CreateReq // Use req.BaseBranch (the user-supplied name) in the user-facing warning // rather than baseRef, which may carry an internal "origin/" form // produced by pullBaseBranch when PullBeforeWorktree is set. - warning = fmt.Sprintf("Requested base branch %q not found, used %q instead", req.BaseBranch, fallback) - detail = fmt.Sprintf("git rev-parse --verify %s failed; recovered using fallback branch %q (typically the repository's default_branch)", baseRef, fallback) + warning := fmt.Sprintf("Requested base branch %q not found, used %q instead", req.BaseBranch, fallback) + detail := fmt.Sprintf("git rev-parse --verify %s failed; recovered using fallback branch %q (typically the repository's default_branch)", baseRef, fallback) // Reflect the resolved branch in the persisted worktree record so // downstream consumers (UI, queries, debug logs) see the actual base // rather than the requested-but-missing one. @@ -536,7 +639,7 @@ func (m *Manager) fetchBranchToLocal(ctx context.Context, repoPath, branch strin return nil, fmt.Errorf("could not verify local branch %q after fetch failure (%s): %w", branch, strings.TrimSpace(outputStr), existsErr) } if !exists { - return nil, fmt.Errorf("branch %q not found locally or on remote: %s", branch, outputStr) + return nil, fmt.Errorf("%w: branch %q not found locally or on remote: %s", ErrInvalidBaseBranch, branch, outputStr) } reason := classifyGitFallbackReason(err, outputStr, fetchCtxErr) diff --git a/apps/backend/internal/worktree/manager_live_default_test.go b/apps/backend/internal/worktree/manager_live_default_test.go new file mode 100644 index 0000000000..189a81237b --- /dev/null +++ b/apps/backend/internal/worktree/manager_live_default_test.go @@ -0,0 +1,165 @@ +package worktree + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestResolveRemoteDefaultBranchReadsOriginHeadBeforeRunningGit(t *testing.T) { + repoPath := t.TempDir() + gitDir := filepath.Join(repoPath, ".git") + if err := os.MkdirAll(filepath.Join(gitDir, "refs", "remotes", "origin"), 0o755); err != nil { + t.Fatalf("mkdir refs: %v", err) + } + if err := os.WriteFile(filepath.Join(gitDir, "refs", "remotes", "origin", "HEAD"), []byte("ref: refs/remotes/origin/main\n"), 0o644); err != nil { + t.Fatalf("write origin HEAD: %v", err) + } + + scriptDir := writeFakeGitScript(t, `echo "unexpected git invocation" >&2; exit 99`) + t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH")) + mgr := newLiveDefaultTestManager(t) + + branch, err := mgr.ResolveRemoteDefaultBranch(context.Background(), repoPath) + if err != nil { + t.Fatalf("ResolveRemoteDefaultBranch() error: %v", err) + } + if branch != "main" { + t.Fatalf("branch = %q, want main", branch) + } +} + +func TestResolveRemoteDefaultBranchRefreshesOriginHeadNonInteractively(t *testing.T) { + repoPath := t.TempDir() + if err := os.MkdirAll(filepath.Join(repoPath, ".git", "refs", "remotes", "origin"), 0o755); err != nil { + t.Fatalf("mkdir refs: %v", err) + } + logPath := filepath.Join(t.TempDir(), "git.log") + scriptDir := writeFakeGitScript(t, ` + case "${1:-}" in + remote) + printf '%s %s %s|%s|%s|%s\n' "${1:-}" "${2:-}" "${3:-}" "${GIT_TERMINAL_PROMPT:-}" "${GCM_INTERACTIVE:-}" "${GIT_SSH_COMMAND:-}" >> "${KD_GIT_LOG:?}" + printf 'ref: refs/remotes/origin/main\n' > "$PWD/.git/refs/remotes/origin/HEAD" + exit 0 + ;; + *) + echo "unexpected command" >&2 + exit 99 + ;; +esac +`) + t.Setenv("KD_GIT_LOG", logPath) + t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH")) + mgr := newLiveDefaultTestManager(t) + + branch, err := mgr.ResolveRemoteDefaultBranch(context.Background(), repoPath) + if err != nil { + t.Fatalf("ResolveRemoteDefaultBranch() error: %v", err) + } + if branch != "main" { + t.Fatalf("branch = %q, want main", branch) + } + log, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read command log: %v", err) + } + if !strings.Contains(string(log), "remote set-head origin") { + t.Fatalf("command log %q does not contain remote set-head origin", string(log)) + } + if !strings.Contains(string(log), "|0|Never|") { + t.Fatalf("command log %q does not show the noninteractive git environment", string(log)) + } +} + +func TestResolveRemoteDefaultBranchClassifiesAuthFailure(t *testing.T) { + scriptDir := writeFakeGitScript(t, `echo "fatal: Authentication failed" >&2; exit 1`) + t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH")) + mgr := newLiveDefaultTestManager(t) + + _, err := mgr.ResolveRemoteDefaultBranch(context.Background(), t.TempDir()) + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("error = %v, want ErrAuthFailed", err) + } +} + +func TestResolveRemoteDefaultBranchClassifiesNetworkFailure(t *testing.T) { + scriptDir := writeFakeGitScript(t, `echo "fatal: Could not resolve host: github.com" >&2; exit 1`) + t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH")) + mgr := newLiveDefaultTestManager(t) + + _, err := mgr.ResolveRemoteDefaultBranch(context.Background(), t.TempDir()) + if !errors.Is(err, ErrRemoteDefaultNetwork) { + t.Fatalf("error = %v, want ErrRemoteDefaultNetwork", err) + } +} + +func TestResolveRemoteDefaultBranchClassifiesMissingRemoteHead(t *testing.T) { + scriptDir := writeFakeGitScript(t, `echo "error: Cannot determine remote HEAD" >&2; exit 1`) + t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH")) + mgr := newLiveDefaultTestManager(t) + + _, err := mgr.ResolveRemoteDefaultBranch(context.Background(), t.TempDir()) + if !errors.Is(err, ErrRemoteDefaultUnresolved) { + t.Fatalf("error = %v, want ErrRemoteDefaultUnresolved", err) + } +} + +func TestResolveRemoteDefaultBranchPreservesTimeout(t *testing.T) { + scriptDir := writeFakeGitScript(t, `sleep 30`) + t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH")) + mgr := newLiveDefaultTestManager(t) + mgr.inspectTimeout = 100 * time.Millisecond + + _, err := mgr.ResolveRemoteDefaultBranch(context.Background(), t.TempDir()) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error = %v, want context deadline", err) + } +} + +func TestResolveRemoteDefaultBranchReportsUnresolvedResult(t *testing.T) { + scriptDir := writeFakeGitScript(t, `exit 0`) + t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH")) + mgr := newLiveDefaultTestManager(t) + + _, err := mgr.ResolveRemoteDefaultBranch(context.Background(), t.TempDir()) + if !errors.Is(err, ErrRemoteDefaultUnresolved) { + t.Fatalf("error = %v, want ErrRemoteDefaultUnresolved", err) + } +} + +func TestResolveBaseRefWithFallbackUsesLiveRemoteDefault(t *testing.T) { + mgr := newLiveDefaultTestManager(t) + repoPath := initGitRepoForWorktreeTest(t) + originHead := filepath.Join(repoPath, ".git", "refs", "remotes", "origin") + if err := os.MkdirAll(originHead, 0o755); err != nil { + t.Fatalf("mkdir origin refs: %v", err) + } + if err := os.WriteFile(filepath.Join(originHead, "HEAD"), []byte("ref: refs/remotes/origin/main\n"), 0o644); err != nil { + t.Fatalf("write origin HEAD: %v", err) + } + + req := CreateRequest{RepositoryPath: repoPath, BaseBranch: "stale-base"} + base, warning, detail, err := mgr.resolveBaseRefWithFallback(context.Background(), &req) + if err != nil { + t.Fatalf("resolveBaseRefWithFallback() error: %v", err) + } + if base != "main" || req.BaseBranch != "main" { + t.Fatalf("resolved base = %q, request base = %q, want main", base, req.BaseBranch) + } + if warning == "" || detail == "" { + t.Fatalf("expected fallback warning and detail, got %q / %q", warning, detail) + } +} + +func newLiveDefaultTestManager(t *testing.T) *Manager { + t.Helper() + mgr, err := NewManager(newTestConfig(t), newMockStore(), newTestLogger()) + if err != nil { + t.Fatalf("NewManager failed: %v", err) + } + return mgr +} diff --git a/apps/backend/internal/worktree/manager_test.go b/apps/backend/internal/worktree/manager_test.go index fcbe060f5f..87a015f750 100644 --- a/apps/backend/internal/worktree/manager_test.go +++ b/apps/backend/internal/worktree/manager_test.go @@ -991,6 +991,9 @@ esac if !strings.Contains(err.Error(), "not found locally or on remote") { t.Fatalf("unexpected error message: %v", err) } + if !errors.Is(err, ErrInvalidBaseBranch) { + t.Fatalf("error = %v, want ErrInvalidBaseBranch", err) + } } func TestFetchBranchToLocal_MissingRemoteRefReturnsError(t *testing.T) { @@ -1033,6 +1036,9 @@ esac if !strings.Contains(err.Error(), "couldn't find remote ref") { t.Fatalf("expected error to contain git output, got: %v", err) } + if !errors.Is(err, ErrInvalidBaseBranch) { + t.Fatalf("error = %v, want ErrInvalidBaseBranch", err) + } } // TestCreate_MissingTaskDirFields_ReturnsErrTaskDirRequired locks in the guard diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index ca7dacb49b..452e127a61 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -84,6 +84,7 @@ const ( ActionTaskSessionList = "task.session.list" ActionTaskSessionStatus = "task.session.status" + ActionTaskLaunchRecover = "task.launch.recover" // Unified session launch ActionSessionLaunch = "session.launch" diff --git a/apps/web/app/office/tasks/[id]/map-office-task.ts b/apps/web/app/office/tasks/[id]/map-office-task.ts index cadce4300b..6761ba09ed 100644 --- a/apps/web/app/office/tasks/[id]/map-office-task.ts +++ b/apps/web/app/office/tasks/[id]/map-office-task.ts @@ -16,7 +16,13 @@ export function mapDecisionDTO(d: TaskDecisionDTO): TaskDecision { }; } -export function mapOfficeTaskToTask(raw: OfficeTask): Task { +export function mapOfficeTaskToTask( + raw: OfficeTask, + supplement?: { + repositories?: Task["repositories"]; + status_summary?: Task["statusSummary"]; + }, +): Task { // The server DTO includes reviewers/approvers/decisions even though the // strongly-typed OfficeTask only declares the cross-cutting fields. We // read those extra props off the raw object. @@ -25,6 +31,7 @@ export function mapOfficeTaskToTask(raw: OfficeTask): Task { approvers?: string[]; decisions?: TaskDecisionDTO[]; blockedBy?: string[]; + status_summary?: Task["statusSummary"]; }; return { id: raw.id, @@ -61,5 +68,7 @@ export function mapOfficeTaskToTask(raw: OfficeTask): Task { updatedAt: raw.updatedAt, executionPolicy: raw.executionPolicy, executionState: raw.executionState, + statusSummary: raw.statusSummary ?? extra.status_summary ?? supplement?.status_summary ?? null, + repositories: raw.repositories ?? supplement?.repositories, }; } diff --git a/apps/web/app/office/tasks/[id]/page.tsx b/apps/web/app/office/tasks/[id]/page.tsx index c76c3f579d..4c07b4dfd2 100644 --- a/apps/web/app/office/tasks/[id]/page.tsx +++ b/apps/web/app/office/tasks/[id]/page.tsx @@ -1,6 +1,16 @@ "use client"; -import { use, useState, useEffect, useRef, useCallback, useMemo, Suspense } from "react"; +import { + use, + useState, + useEffect, + useRef, + useCallback, + useMemo, + Suspense, + type Dispatch, + type SetStateAction, +} from "react"; import { useRouter, useSearchParams } from "@/lib/routing/client-router"; import { useAppStore } from "@/components/state-provider"; import { useOfficeRefetch } from "@/hooks/use-office-refetch"; @@ -12,6 +22,7 @@ import { listComments, type TaskCommentResponse, } from "@/lib/api/domains/office-api"; +import { fetchTask } from "@/lib/api/domains/kanban-api"; import { listTaskSessions } from "@/lib/api/domains/session-api"; import { liveSessionMetadataFromStore, @@ -261,10 +272,10 @@ function useTaskOptimisticHelpers( const refetchTask = useCallback(async () => { const token = begin(); try { - const res = await getTask(id); + const [res, genericTask] = await Promise.all([getTask(id), fetchTask(id).catch(() => null)]); if (!isCurrent(token)) return; if (res.task) { - setTask(mapOfficeTaskToTask(res.task)); + setTask(mapOfficeTaskToTask(res.task, genericTask ?? undefined)); if (res.timeline) setTimeline(res.timeline); } } catch { @@ -292,18 +303,43 @@ function useTaskOptimisticHelpers( return { applyTaskPatch, restoreTask }; } +function useTaskDetailRefetch( + setTask: Dispatch>, + setTimeline: Dispatch>, +) { + return useCallback( + (updated: Task, updatedTimeline: TimelineEvent[]) => { + setTask((previous) => + previous + ? { + ...updated, + // Office's live refetch endpoint does not carry the generic + // status summary or repository rows. Preserve the supplement + // loaded by the initial detail request until the next full load. + statusSummary: updated.statusSummary ?? previous.statusSummary, + repositories: updated.repositories ?? previous.repositories, + } + : updated, + ); + setTimeline(updatedTimeline); + }, + [setTask, setTimeline], + ); +} + // --------------------------------------------------------------------------- // Primary data hook // --------------------------------------------------------------------------- +// Snapshot storeIssues in a ref so the load effect can seed `task` from the +// store without re-running on every store update. Re-running the GET +// on store changes would race with in-flight optimistic mutations (the WS- +// driven refetch in useTaskOptimisticHelpers handles canonical refresh after +// a property mutation commits). +// `errorKey` remains a catalog key so locale changes do not re-issue the load. function useIssueData(id: string) { const storeIssues = useAppStore((s) => s.office.tasks.items); const setTaskSessionsForTask = useAppStore((s) => s.setTaskSessionsForTask); - // Snapshot storeIssues in a ref so the load effect can seed `task` from - // the store without re-running on every store update. Re-running the GET - // on store changes would race with in-flight optimistic mutations (the - // WS-driven refetch in useTaskOptimisticHelpers handles canonical - // refresh after a property mutation commits). const storeIssuesRef = useRef(storeIssues); useEffect(() => { storeIssuesRef.current = storeIssues; @@ -315,9 +351,6 @@ function useIssueData(id: string) { const [activity, setActivity] = useState([]); const [baseSessions, setBaseSessions] = useState([]); const [loading, setLoading] = useState(true); - // Holds a catalog KEY, not a message: the load effect must not take `t` in - // its dep array (a locale switch would re-issue the task fetch), so the key is - // stored and resolved at render instead. const [errorKey, setErrorKey] = useState(null); const applyDetail = useCallback( @@ -340,12 +373,15 @@ function useIssueData(id: string) { if (fromStore && !cancelled) setTask(mapOfficeTaskToTask(fromStore)); try { - const res = await getTask(id); + const [res, genericTask] = await Promise.all([ + getTask(id), + fetchTask(id).catch(() => null), + ]); if (cancelled) return; if (!res.task) { if (!fromStore) setErrorKey("office:taskNotFound"); } else { - const freshTask = mapOfficeTaskToTask(res.task); + const freshTask = mapOfficeTaskToTask(res.task, genericTask ?? undefined); setTask(freshTask); if (res.timeline) setTimeline(res.timeline); const detail = await fetchIssueDetailData(freshTask.workspaceId, id); @@ -369,10 +405,7 @@ function useIssueData(id: string) { setComments(result); }, [id]); - const onTaskRefetch = useCallback((updated: Task, updatedTimeline: TimelineEvent[]) => { - setTask(updated); - setTimeline(updatedTimeline); - }, []); + const onTaskRefetch = useTaskDetailRefetch(setTask, setTimeline); const { sessionStoreStates, sessionStoreMetadata } = useSessionLiveSync({ task, @@ -392,7 +425,6 @@ function useIssueData(id: string) { [baseSessions, sessionStoreStates, sessionStoreMetadata], ); - // Refetch comments when a new comment is created via office WS event useOfficeRefetch("comments", fetchComments); const { applyTaskPatch, restoreTask } = useTaskOptimisticHelpers(id, setTask, setTimeline); diff --git a/apps/web/app/office/tasks/[id]/types.ts b/apps/web/app/office/tasks/[id]/types.ts index a8c70d2a7a..c4274043dc 100644 --- a/apps/web/app/office/tasks/[id]/types.ts +++ b/apps/web/app/office/tasks/[id]/types.ts @@ -1,4 +1,7 @@ import type { TaskPriority } from "@/lib/types/http"; +import type { TaskRepository } from "@/lib/types/http"; +import type { TaskLaunchRecoveryAction } from "@/lib/types/task-launch-error"; +import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; export type { TaskPriority } from "@/lib/types/http"; /** @@ -106,6 +109,10 @@ export type RunError = { remediationUrl?: string; failureCode?: string; failureDetails?: string; + message?: string; + recoveryActions?: TaskLaunchRecoveryAction[]; + taskRepositoryId?: string; + errorStamp?: string; }; export type TaskLabelLocal = { @@ -170,4 +177,6 @@ export type Task = { updatedAt: string; executionPolicy?: string; executionState?: string; + statusSummary?: TaskStatusSummary | null; + repositories?: TaskRepository[]; }; diff --git a/apps/web/components/task/base-branch-picker.tsx b/apps/web/components/task/base-branch-picker.tsx index 2db7119a9e..19c57e4d81 100644 --- a/apps/web/components/task/base-branch-picker.tsx +++ b/apps/web/components/task/base-branch-picker.tsx @@ -10,7 +10,8 @@ import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id"; import { invalidateCumulativeDiffCache } from "@/hooks/domains/session/use-cumulative-diff"; import { updateTaskRepositoryBaseBranch } from "@/lib/api/domains/kanban-api"; import { useToast } from "@/components/toast-provider"; -import { repositoryId, type Branch, type Repository } from "@/lib/types/http"; +import { repositoryId, type Repository } from "@/lib/types/http"; +import { BranchPickerList } from "./branch-picker-list"; import { useTranslation } from "react-i18next"; type ResolvedRepo = { @@ -125,107 +126,6 @@ function usePickerLogic(taskId: string | null, repositoryName: string, fallbackB }; } -function BranchList({ - branches, - isLoadingBranches, - currentBase, - onSelect, -}: { - branches: Branch[]; - isLoadingBranches: boolean; - currentBase: string; - onSelect: (name: string) => void; -}) { - const { t } = useTranslation(); - const [filter, setFilter] = useState(""); - // Dedupe by name (local + remote variants collapse to one option) so the - // list shows each branch once even when the API returns both kinds. - const uniqueByName = useMemo(() => { - const seen = new Set(); - const out: Branch[] = []; - for (const b of branches) { - if (seen.has(b.name)) continue; - seen.add(b.name); - out.push(b); - } - return out; - }, [branches]); - const filtered = useMemo(() => { - const q = filter.trim().toLowerCase(); - if (!q) return uniqueByName; - return uniqueByName.filter((b) => b.name.toLowerCase().includes(q)); - }, [filter, uniqueByName]); - - return ( -
- setFilter(e.target.value)} - placeholder={t("task:filterBranches")} - data-testid="base-branch-picker-filter" - className="w-full rounded border border-input bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" - autoFocus - /> - -
- ); -} - -function BranchListBody({ - filtered, - isLoadingBranches, - currentBase, - onSelect, -}: { - filtered: Branch[]; - isLoadingBranches: boolean; - currentBase: string; - onSelect: (name: string) => void; -}) { - const { t } = useTranslation(); - if (isLoadingBranches && filtered.length === 0) { - return ( -
- - {t("task:loadingBranches")} -
- ); - } - if (filtered.length === 0) { - return ( -
- {t("task:noMatchingBranches")} -
- ); - } - return ( - <> - {filtered.map((b) => ( - - ))} - - ); -} - /** * Compare-against picker rendered inline inside the branch hover card. * Replaces the static base branch label with a click-to-edit trigger. @@ -273,7 +173,7 @@ export function BaseBranchPicker({ role="listbox" onOpenAutoFocus={(e) => e.preventDefault()} > - cleanup()); + +describe("BranchPickerList", () => { + it("can limit recovery choices to remote branches", () => { + const branches: Branch[] = [ + { name: "feature/local-only", type: "local" }, + { name: "main", type: "remote", remote: "origin" }, + ]; + + render( + {}} + remoteOnly + testIdPrefix="recovery-branch" + />, + ); + + expect(screen.queryByTestId("recovery-branch-option-feature/local-only")).toBeNull(); + expect(screen.getByTestId("recovery-branch-option-main")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/branch-picker-list.tsx b/apps/web/components/task/branch-picker-list.tsx new file mode 100644 index 0000000000..23caf54810 --- /dev/null +++ b/apps/web/components/task/branch-picker-list.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { IconLoader2 } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import type { Branch } from "@/lib/types/http"; +import { useTranslation } from "react-i18next"; + +type BranchPickerListProps = { + branches: Branch[]; + isLoadingBranches: boolean; + currentBase: string; + onSelect: (name: string) => void; + remoteOnly?: boolean; + testIdPrefix?: string; +}; + +/** Shared, bounded branch list used by desktop popovers and phone sheets. */ +export function BranchPickerList({ + branches, + isLoadingBranches, + currentBase, + onSelect, + remoteOnly = false, + testIdPrefix = "base-branch-picker", +}: BranchPickerListProps) { + const { t } = useTranslation(); + const [filter, setFilter] = useState(""); + const uniqueByName = useMemo(() => { + const seen = new Set(); + return branches.filter((branch) => { + if (remoteOnly && branch.type !== "remote") return false; + if (seen.has(branch.name)) return false; + seen.add(branch.name); + return true; + }); + }, [branches, remoteOnly]); + const filtered = useMemo(() => { + const query = filter.trim().toLowerCase(); + return query + ? uniqueByName.filter((branch) => branch.name.toLowerCase().includes(query)) + : uniqueByName; + }, [filter, uniqueByName]); + + return ( +
+ setFilter(event.target.value)} + placeholder={t("task:filterBranches")} + data-testid={`${testIdPrefix}-filter`} + className="min-h-11 w-full rounded border border-input bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-ring sm:min-h-8" + autoFocus + /> + +
+ ); +} + +function BranchPickerListBody({ + branches, + isLoadingBranches, + currentBase, + onSelect, + testIdPrefix, +}: { + branches: Branch[]; + isLoadingBranches: boolean; + currentBase: string; + onSelect: (name: string) => void; + testIdPrefix: string; +}) { + const { t } = useTranslation(); + if (isLoadingBranches && branches.length === 0) { + return ( +
+ + {t("task:loadingBranches")} +
+ ); + } + if (branches.length === 0) { + return ( +
+ {t("task:noMatchingBranches")} +
+ ); + } + return ( + <> + {branches.map((branch) => ( + + ))} + + ); +} diff --git a/apps/web/components/task/mobile/mobile-picker-sheet.tsx b/apps/web/components/task/mobile/mobile-picker-sheet.tsx index 177dcae8da..e5f962cc38 100644 --- a/apps/web/components/task/mobile/mobile-picker-sheet.tsx +++ b/apps/web/components/task/mobile/mobile-picker-sheet.tsx @@ -14,6 +14,8 @@ type MobilePickerSheetProps = { onOpenChange: (open: boolean) => void; title: string; description?: string; + /** Optional stable hook for a picker that owns the sheet scroll area. */ + contentTestId?: string; /** Optional trailing element rendered to the right of the title (e.g. a "+" CTA). */ headerAction?: ReactNode; children: ReactNode; @@ -29,6 +31,7 @@ export function MobilePickerSheet({ onOpenChange, title, description, + contentTestId, headerAction, children, }: MobilePickerSheetProps) { @@ -42,7 +45,12 @@ export function MobilePickerSheet({ {description && {description}} -
{children}
+
+ {children} +
); diff --git a/apps/web/components/task/simple/OfficeSimplePane.tsx b/apps/web/components/task/simple/OfficeSimplePane.tsx index 0dd3a33a54..7d58e0bf44 100644 --- a/apps/web/components/task/simple/OfficeSimplePane.tsx +++ b/apps/web/components/task/simple/OfficeSimplePane.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useCallback, useRef } from "react"; +import { useEffect, useState, useCallback, useRef, useMemo } from "react"; import { useTranslation } from "react-i18next"; import Link from "@/components/routing/app-link"; import { @@ -58,6 +58,7 @@ import type { TimelineEvent, } from "@/app/office/tasks/[id]/types"; import { toast } from "@/lib/toast/sonner"; +import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary"; const COMMENTABLE_DONE_SESSION_STATES = new Set([ "CREATED", @@ -78,6 +79,17 @@ type OfficeSimplePaneProps = { onCommentsChanged?: () => void; }; +function useChatTaskWithLiveStatusSummary(task: Task): Task { + const statusSummary = useTaskStatusSummary(task.id, task.statusSummary); + return useMemo( + () => ({ + ...task, + statusSummary, + }), + [statusSummary, task], + ); +} + function sessionSortTime(session: TaskSession): number { const value = session.updatedAt ?? session.completedAt ?? session.startedAt ?? ""; const time = Date.parse(value); @@ -469,6 +481,7 @@ export function OfficeSimplePane({ const [subIssueOpen, setSubIssueOpen] = useState(false); const [scrollParent, setScrollParent] = useState(null); const { treePreview, activeHold, refreshTreePreview } = useTaskTreePreview(task.id); + const chatTask = useChatTaskWithLiveStatusSummary(task); return ( @@ -525,7 +538,7 @@ export function OfficeSimplePane({ { }); }); +describe("hasMatchingSessionLaunchError", () => { + it("does not match an unstamped run error to a stamped summary", () => { + expect( + hasMatchingSessionLaunchError("s1", "new-stamp", [ + { id: "e1", sessionId: "s1", rawPayload: "boom", failedAt: "2026-08-02T15:00:00Z" }, + ]), + ).toBe(false); + }); + + it("matches only the same explicit stamp", () => { + expect( + hasMatchingSessionLaunchError("s1", "new-stamp", [ + { + id: "e1", + sessionId: "s1", + rawPayload: "boom", + failedAt: "2026-08-02T15:00:00Z", + errorStamp: "new-stamp", + }, + ]), + ).toBe(true); + }); +}); + describe("liveSessionMetadataFromStore", () => { const metadata = { last_agent_error: { message: "boom", remediation_url: URL } }; diff --git a/apps/web/components/task/simple/chat-entries.ts b/apps/web/components/task/simple/chat-entries.ts index 209e4ba140..d4eea92b2b 100644 --- a/apps/web/components/task/simple/chat-entries.ts +++ b/apps/web/components/task/simple/chat-entries.ts @@ -1,6 +1,7 @@ import type { CommentTurnContext } from "./turn-context"; import { groupSortKey, type SessionGroup } from "./session-groups"; import { normalizeRemediationUrl } from "@/lib/remediation-url"; +import { lastAgentErrorStamp, readLastAgentError } from "@/lib/session-last-agent-error"; import type { RunError, TaskComment, @@ -58,6 +59,7 @@ export function buildRunErrorsFromSessions(sessions: TaskSession[]): RunError[] if (s.state !== "FAILED") continue; const failedAt = s.completedAt ?? s.updatedAt ?? s.startedAt ?? ""; const lastError = lastAgentErrorFromSessionMetadata(s.metadata); + const parsedLastError = readLastAgentError(s.metadata); errors.push({ id: `re-${s.id}`, sessionId: s.id, @@ -66,6 +68,10 @@ export function buildRunErrorsFromSessions(sessions: TaskSession[]): RunError[] failedAt, failureCode: lastError?.code, failureDetails: lastError?.details, + message: parsedLastError?.message, + recoveryActions: parsedLastError?.recoveryActions, + taskRepositoryId: parsedLastError?.taskRepositoryId, + errorStamp: parsedLastError ? lastAgentErrorStamp(parsedLastError) : undefined, remediationUrl: normalizeRemediationUrl(remediationUrlFromSessionMetadata(s.metadata)) ?? undefined, }); @@ -73,6 +79,18 @@ export function buildRunErrorsFromSessions(sessions: TaskSession[]): RunError[] return errors; } +export function hasMatchingSessionLaunchError( + summarySessionId: string | undefined, + summaryStamp: string | undefined, + runErrors: RunError[], +): boolean { + if (!summarySessionId) return false; + return runErrors.some( + (error) => + error.sessionId === summarySessionId && (!summaryStamp || error.errorStamp === summaryStamp), + ); +} + export function mergeLiveSessionMetadata( base: Record | null | undefined, live: Record | null | undefined, diff --git a/apps/web/components/task/simple/components/run-error-entry.test.tsx b/apps/web/components/task/simple/components/run-error-entry.test.tsx new file mode 100644 index 0000000000..eed5ac4517 --- /dev/null +++ b/apps/web/components/task/simple/components/run-error-entry.test.tsx @@ -0,0 +1,49 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RunError } from "@/app/office/tasks/[id]/types"; +import { RunErrorEntry } from "./run-error-entry"; + +const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() })); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: unknown) => unknown) => selector({}), +})); +vi.mock("@/lib/state/slices/office/selectors", () => ({ + selectOfficeAgentProfiles: () => [], +})); +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => ({ request: requestMock }), +})); + +afterEach(() => cleanup()); + +function runError(failureCode: string): RunError { + return { + id: "run-1", + sessionId: "session-1", + agentProfileId: "agent-1", + rawPayload: "provider raw details", + failedAt: "2026-08-20T10:00:00Z", + failureCode, + errorStamp: "ordinary-failure-stamp", + message: "provider failure", + remediationUrl: "https://opencode.ai/workspace/demo_workspace/go", + }; +} + +describe("RunErrorEntry", () => { + it.each(["provider_auth_required", "model_capacity"])( + "keeps ordinary failure code %s on the resumable error surface", + (failureCode) => { + render( + , + ); + + expect(screen.getByTestId("run-error-resume-button")).toBeTruthy(); + expect(screen.getByTestId("run-error-fresh-button")).toBeTruthy(); + expect(screen.queryByTestId("run-error-raw-payload")).toBeNull(); + expect(screen.getByTestId("remediation-link")).toBeTruthy(); + expect(screen.queryByTestId("task-launch-error-entry")).toBeNull(); + }, + ); +}); diff --git a/apps/web/components/task/simple/components/run-error-entry.tsx b/apps/web/components/task/simple/components/run-error-entry.tsx index d546716147..7f9aaf8fd7 100644 --- a/apps/web/components/task/simple/components/run-error-entry.tsx +++ b/apps/web/components/task/simple/components/run-error-entry.tsx @@ -16,55 +16,54 @@ import { formatRelativeTime } from "@/lib/utils"; import { AgentAvatar } from "@/app/office/components/agent-avatar"; import { RemediationLink } from "@/components/task/remediation-link"; import type { RunError } from "@/app/office/tasks/[id]/types"; +import type { TaskRepository } from "@/lib/types/http"; import { ManagedRuntimeNpmRunError } from "./managed-runtime-npm-run-error"; +import { isLaunchErrorCategory, TaskLaunchErrorEntry } from "./task-launch-error-entry"; import { useTranslation } from "react-i18next"; type RunErrorEntryProps = { taskId: string; + workspaceId?: string; + repositories?: TaskRepository[]; error: RunError; }; -/** - * Top-level chat entry rendered when an office session is in FAILED - * state. Replaces the legacy red action-message banner: shows a short - * generic header, a Show details collapsible exposing the verbatim - * raw payload (for bug reports), and the Resume / Start fresh - * buttons. Click handlers wire to the existing `session.recover` WS - * request so the recovery semantics are unchanged. - */ -export function RunErrorEntry({ taskId, error }: RunErrorEntryProps) { - const { t } = useTranslation(); - const agentName = useAppStore((s) => - error.agentProfileId - ? (selectOfficeAgentProfiles(s).find((a) => a.id === error.agentProfileId)?.name ?? - t("task:agent")) - : t("task:agent"), - ); - const [showDetails, setShowDetails] = useState(false); - - const handleRecover = async (action: "resume" | "fresh_start" | "runtime_retry") => { - const client = getWebSocketClient(); - if (!client) return; - try { - await client.request("session.recover", { - task_id: taskId, +function TypedRunLaunchErrorEntry({ + taskId, + workspaceId, + repositories, + error, + preview, +}: RunErrorEntryProps & { preview: string }) { + return ( + + ); +} - if (error.failureCode === "managed_runtime_npm_resolution") { - return ( - handleRecover("runtime_retry")} - /> - ); - } +function LegacyRunErrorEntry({ + agentName, + error, + onRecover, +}: { + agentName: string; + error: RunError; + onRecover: (action: "resume" | "fresh_start") => Promise; +}) { + const { t } = useTranslation(); + const [showDetails, setShowDetails] = useState(false); return (
@@ -105,7 +104,7 @@ export function RunErrorEntry({ taskId, error }: RunErrorEntryProps) { variant="outline" size="sm" className="h-7 text-xs cursor-pointer gap-1.5" - onClick={() => handleRecover("resume")} + onClick={() => onRecover("resume")} data-testid="run-error-resume-button" > @@ -115,7 +114,7 @@ export function RunErrorEntry({ taskId, error }: RunErrorEntryProps) { variant="outline" size="sm" className="h-7 text-xs cursor-pointer gap-1.5" - onClick={() => handleRecover("fresh_start")} + onClick={() => onRecover("fresh_start")} data-testid="run-error-fresh-button" > @@ -126,3 +125,63 @@ export function RunErrorEntry({ taskId, error }: RunErrorEntryProps) {
); } + +/** + * Top-level chat entry rendered when an office session is in FAILED + * state. Replaces the legacy red action-message banner: shows a short + * generic header, a Show details collapsible exposing the verbatim + * raw payload (for bug reports), and the Resume / Start fresh + * buttons. Click handlers wire to the existing `session.recover` WS + * request so the recovery semantics are unchanged. + */ +export function RunErrorEntry({ + taskId, + workspaceId = "", + repositories, + error, +}: RunErrorEntryProps) { + const { t } = useTranslation(); + const agentName = useAppStore( + (s) => + selectOfficeAgentProfiles(s).find((a) => a.id === error.agentProfileId)?.name ?? + t("task:agent"), + ); + + if (isLaunchErrorCategory(error.failureCode) && error.errorStamp) { + return ( + + ); + } + + const handleRecover = async (action: "resume" | "fresh_start" | "runtime_retry") => { + const client = getWebSocketClient(); + if (!client) return; + try { + await client.request("session.recover", { + task_id: taskId, + session_id: error.sessionId, + action, + }); + } catch { + // No-op — the chat will reflect any subsequent state via WS. + } + }; + + if (error.failureCode === "managed_runtime_npm_resolution") { + return ( + handleRecover("runtime_retry")} + /> + ); + } + + return ; +} diff --git a/apps/web/components/task/simple/components/task-chat-launch-error.tsx b/apps/web/components/task/simple/components/task-chat-launch-error.tsx new file mode 100644 index 0000000000..3dfe5251aa --- /dev/null +++ b/apps/web/components/task/simple/components/task-chat-launch-error.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useMemo } from "react"; +import type { TaskRepository } from "@/lib/types/http"; +import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; +import type { RunError } from "@/app/office/tasks/[id]/types"; +import { hasMatchingSessionLaunchError } from "../chat-entries"; +import { isTypedTaskLaunchError, TaskLaunchErrorEntry } from "./task-launch-error-entry"; + +type TaskChatLaunchErrorProps = { + taskId: string; + workspaceId: string; + statusSummary?: TaskStatusSummary | null; + runErrors: RunError[]; + repositories?: TaskRepository[]; +}; + +export function TaskChatLaunchError({ + taskId, + workspaceId, + statusSummary, + runErrors, + repositories, +}: TaskChatLaunchErrorProps) { + const error = useMemo(() => { + const candidate = statusSummary?.active_error; + if (!isTypedTaskLaunchError(candidate)) return null; + if (hasMatchingSessionLaunchError(candidate.session_id, candidate.stamp, runErrors)) { + return null; + } + return candidate; + }, [runErrors, statusSummary]); + + if (!error) return null; + return ( + + ); +} diff --git a/apps/web/components/task/simple/components/task-launch-error-entry.test.tsx b/apps/web/components/task/simple/components/task-launch-error-entry.test.tsx new file mode 100644 index 0000000000..860c09f7db --- /dev/null +++ b/apps/web/components/task/simple/components/task-launch-error-entry.test.tsx @@ -0,0 +1,215 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { isTypedTaskLaunchError, TaskLaunchErrorEntry } from "./task-launch-error-entry"; +import { TaskChatLaunchError } from "./task-chat-launch-error"; +import type { TaskRepository } from "@/lib/types/http"; +import type { TaskStatusSummaryActiveError } from "@/lib/types/task-status-summary"; + +const { requestMock, toastMock } = vi.hoisted(() => ({ + requestMock: vi.fn(), + toastMock: vi.fn(), +})); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => ({ request: requestMock }), +})); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: toastMock }), +})); + +vi.mock("@/components/task/task-launch-branch-picker", () => ({ + TaskLaunchBranchPicker: ({ + trigger, + onSelect, + }: { + trigger: ReactNode; + onSelect: (branch: string) => Promise; + }) => ( + <> + {trigger} + + + ), +})); + +const TASK_ID = "task-1"; +const TASK_REPOSITORY_ID = "task-repo-1"; +const ERROR_STAMP = "launch-stamp-1"; + +const error: TaskStatusSummaryActiveError = { + task_repository_id: TASK_REPOSITORY_ID, + stamp: ERROR_STAMP, + occurred_at: "2026-08-19T10:00:00Z", + preview: "The selected base branch is not available.", + category: "base_branch_missing", + recovery_actions: ["retry_default", "pick_base_branch", "mark_review_done"], +}; + +beforeEach(() => { + requestMock.mockReset().mockResolvedValue({ ok: true }); + toastMock.mockReset(); +}); + +afterEach(() => cleanup()); + +// eslint-disable-next-line max-lines-per-function -- shared recovery-entry fixtures keep these related flows together. +describe("TaskLaunchErrorEntry", () => { + it.each(["provider_auth_required", "model_capacity"])( + "does not classify ordinary failure code %s as a launch error", + (category) => { + expect( + isTypedTaskLaunchError({ + ...error, + category, + }), + ).toBe(false); + }, + ); + + it.each([ + "base_branch_missing", + "default_branch_unresolved", + "pr_already_closed", + "generic_launch_failure", + ])("classifies launch category %s as a typed launch error", (category) => { + expect( + isTypedTaskLaunchError({ + ...error, + category, + }), + ).toBe(true); + }); + + it("renders a typed summary error without recovery actions", () => { + render( + , + ); + + expect(screen.getByTestId("task-launch-error-entry")).toBeTruthy(); + expect(screen.getByText(error.preview)).toBeTruthy(); + expect(screen.queryByTestId("task-launch-retry_default-button")).toBeNull(); + }); + + it("renders the bounded preview and sends the exact task recovery payload", async () => { + render( + , + ); + + expect(screen.getByTestId("task-launch-error-entry")).toBeTruthy(); + expect(screen.getByText(error.preview)).toBeTruthy(); + fireEvent.click(screen.getByTestId("task-launch-retry_default-button")); + + await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1)); + expect(requestMock).toHaveBeenCalledWith("task.launch.recover", { + task_id: TASK_ID, + task_repository_id: TASK_REPOSITORY_ID, + action: "retry_default", + error_stamp: ERROR_STAMP, + }); + }); + + it("includes the selected branch in the row-scoped recovery payload", async () => { + render( + , + ); + + fireEvent.click(screen.getByTestId("mock-branch-option")); + await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1)); + expect(requestMock).toHaveBeenCalledWith("task.launch.recover", { + task_id: TASK_ID, + task_repository_id: TASK_REPOSITORY_ID, + action: "pick_base_branch", + base_branch: "develop", + error_stamp: ERROR_STAMP, + }); + }); + + it("waits for a branch selection before sending the picker action", async () => { + render( + , + ); + + fireEvent.click(screen.getByTestId("task-launch-pick_base_branch-button")); + await waitFor(() => expect(requestMock).not.toHaveBeenCalled()); + }); + + it("coalesces concurrent recovery requests from duplicate task surfaces", async () => { + let resolveRequest: ((value: { ok: boolean }) => void) | undefined; + requestMock.mockImplementation( + () => + new Promise<{ ok: boolean }>((resolve) => { + resolveRequest = resolve; + }), + ); + render( + <> + + + , + ); + + const buttons = screen.getAllByTestId("task-launch-retry_default-button"); + fireEvent.click(buttons[0]); + fireEvent.click(buttons[1]); + + await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1)); + await waitFor(() => { + expect(buttons[0].getAttribute("disabled")).not.toBeNull(); + expect(buttons[1].getAttribute("disabled")).not.toBeNull(); + }); + resolveRequest?.({ ok: true }); + await waitFor(() => expect(screen.queryAllByTestId("task-launch-error-entry")).toHaveLength(2)); + }); +}); diff --git a/apps/web/components/task/simple/components/task-launch-error-entry.tsx b/apps/web/components/task/simple/components/task-launch-error-entry.tsx new file mode 100644 index 0000000000..6331033c11 --- /dev/null +++ b/apps/web/components/task/simple/components/task-launch-error-entry.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useState } from "react"; +import { + IconAlertTriangle, + IconCheck, + IconGitBranch, + IconLoader2, + IconRefresh, +} from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { useToast } from "@/components/toast-provider"; +import { getWebSocketClient } from "@/lib/ws/connection"; +import type { TaskRepository } from "@/lib/types/http"; +import type { TaskLaunchRecoveryAction } from "@/lib/types/task-launch-error"; +import type { TaskStatusSummaryActiveError } from "@/lib/types/task-status-summary"; +import { TaskLaunchBranchPicker } from "@/components/task/task-launch-branch-picker"; +import { useTranslation } from "react-i18next"; + +type TaskLaunchErrorEntryProps = { + taskId: string; + workspaceId: string; + error: TaskStatusSummaryActiveError; + repositories?: TaskRepository[]; +}; + +const pendingRecoveryRequests = new Map>(); + +const TASK_LAUNCH_ERROR_CATEGORIES = new Set([ + "base_branch_missing", + "default_branch_unresolved", + "pr_already_closed", + "generic_launch_failure", +]); + +export function isLaunchErrorCategory(category: string | undefined): boolean { + return Boolean(category && TASK_LAUNCH_ERROR_CATEGORIES.has(category)); +} + +export function isTypedTaskLaunchError( + error: TaskStatusSummaryActiveError | null | undefined, +): error is TaskStatusSummaryActiveError & { category: string } { + return Boolean(error?.stamp && isLaunchErrorCategory(error.category)); +} + +function categoryTitle(category: string | undefined, t: (key: string) => string): string { + switch (category) { + case "base_branch_missing": + return t("task:launchErrorBaseBranchMissing"); + case "default_branch_unresolved": + return t("task:launchErrorDefaultBranchUnresolved"); + case "pr_already_closed": + return t("task:launchErrorPrAlreadyClosed"); + default: + return t("task:launchErrorGeneric"); + } +} + +function actionLabel(action: TaskLaunchRecoveryAction, t: (key: string) => string): string { + switch (action) { + case "retry_default": + return t("task:retryWithDefaultBranch"); + case "pick_base_branch": + return t("task:pickBaseBranch"); + case "mark_review_done": + return t("task:markReviewDone"); + } +} + +function RecoveryActionIcon({ + action, + pending, +}: { + action: TaskLaunchRecoveryAction; + pending: boolean; +}) { + if (pending) return ; + if (action === "retry_default") return ; + if (action === "pick_base_branch") return ; + return ; +} + +function TaskLaunchRecoveryActions({ + actions, + workspaceId, + repositories, + taskRepositoryId, + currentBase, + pendingAction, + onRecover, +}: { + actions: TaskLaunchRecoveryAction[]; + workspaceId: string; + repositories?: TaskRepository[]; + taskRepositoryId?: string; + currentBase: string; + pendingAction: TaskLaunchRecoveryAction | null; + onRecover: (action: TaskLaunchRecoveryAction, baseBranch?: string) => Promise; +}) { + const { t } = useTranslation(); + return ( +
+ {actions.map((action) => { + const opensBranchPicker = action === "pick_base_branch"; + const button = ( + + ); + if (action !== "pick_base_branch") return {button}; + return ( + onRecover(action, branch)} + trigger={button} + /> + ); + })} +
+ ); +} + +export function TaskLaunchErrorEntry({ + taskId, + workspaceId, + error, + repositories, +}: TaskLaunchErrorEntryProps) { + const { t } = useTranslation(); + const { toast } = useToast(); + const [pendingAction, setPendingAction] = useState(null); + const taskRepository = repositories?.find( + (repository) => repository.id === error.task_repository_id, + ); + const currentBase = taskRepository?.base_branch ?? t("task:baseBranchNotSet"); + + const sendRecovery = async (action: TaskLaunchRecoveryAction, baseBranch?: string) => { + const client = getWebSocketClient(); + if (!client || pendingAction) return; + const requestKey = JSON.stringify({ taskId, stamp: error.stamp, action, baseBranch }); + const existingRequest = pendingRecoveryRequests.get(requestKey); + if (existingRequest) { + setPendingAction(action); + try { + await existingRequest; + } catch { + // The request owner reports the recovery error to avoid duplicate toasts. + } finally { + setPendingAction(null); + } + return; + } + setPendingAction(action); + const request = Promise.resolve().then(() => + client.request("task.launch.recover", { + task_id: taskId, + ...(error.session_id ? { session_id: error.session_id } : {}), + ...(error.task_repository_id ? { task_repository_id: error.task_repository_id } : {}), + action, + ...(baseBranch ? { base_branch: baseBranch } : {}), + error_stamp: error.stamp, + }), + ); + pendingRecoveryRequests.set(requestKey, request); + try { + await request; + } catch { + toast({ + title: t("task:launchRecoveryFailed"), + description: t("task:tryRecoveryAgain"), + variant: "error", + }); + } finally { + pendingRecoveryRequests.delete(requestKey); + setPendingAction(null); + } + }; + + return ( +
+
+
+
+
+ {categoryTitle(error.category, t)} + {t("task:launchNeedsAttention")} +
+

+ {error.preview} +

+ +
+
+ ); +} diff --git a/apps/web/components/task/simple/task-chat.test.tsx b/apps/web/components/task/simple/task-chat.test.tsx index 6a67195e80..d2fe74a3c9 100644 --- a/apps/web/components/task/simple/task-chat.test.tsx +++ b/apps/web/components/task/simple/task-chat.test.tsx @@ -8,6 +8,7 @@ import type { TaskSession, TimelineEvent, } from "@/app/office/tasks/[id]/types"; +import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; // Stub out everything that would otherwise drag the WS layer / portals into // the test runtime. We assert merge/order, "Show older sessions", and that @@ -549,6 +550,7 @@ describe("TaskChat user comment run badge", () => { }); }); +// eslint-disable-next-line max-lines-per-function -- the grouped chat failure cases share one fixture. describe("TaskChat run error entries", () => { function failedSession( id: string, @@ -632,4 +634,67 @@ describe("TaskChat run error entries", () => { expect(screen.queryByText(/agent stopped with an error/i)).toBeNull(); expect(screen.queryByTestId("run-error-raw-payload")).toBeNull(); }); + + it("renders a task-owned launch error before the empty state", () => { + const statusSummary: TaskStatusSummary = { + revision: 3, + updated_at: T_11, + active_error: { + stamp: "task-stamp-1", + occurred_at: T_11, + preview: "Choose a base branch", + category: "base_branch_missing", + recovery_actions: ["pick_base_branch"], + }, + }; + render( + wrap( + , + ), + ); + expect(screen.getByTestId("task-launch-error-entry")).toBeTruthy(); + expect(screen.getByText(/choose a base branch/i)).toBeTruthy(); + expect(screen.getByText(/no comments yet/i)).toBeTruthy(); + }); + + it("does not duplicate a summary error when its failed session is rendered", () => { + const sessions = [failedSession("s-typed", "agent-typed", T_11, "raw payload")]; + sessions[0].metadata = { + last_agent_error: { + message: "The selected base branch is unavailable.", + code: "base_branch_missing", + recovery_actions: ["retry_default"], + stamp: "session-stamp-1", + }, + }; + render( + wrap( + , + ), + ); + expect(screen.getAllByTestId("task-launch-error-entry")).toHaveLength(1); + }); }); diff --git a/apps/web/components/task/simple/task-chat.tsx b/apps/web/components/task/simple/task-chat.tsx index 8d4d7e134b..0b8c72ef79 100644 --- a/apps/web/components/task/simple/task-chat.tsx +++ b/apps/web/components/task/simple/task-chat.tsx @@ -1,4 +1,5 @@ "use client"; +/* eslint-disable max-lines -- this component owns the chat timeline and composer composition. */ import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { toast } from "@/lib/toast/sonner"; @@ -20,6 +21,7 @@ import { formatRelativeTime } from "@/lib/utils"; import { MarkdownComment } from "./markdown-comment"; import { AgentTurnPanel } from "./components/agent-turn-panel"; import { RunErrorEntry } from "./components/run-error-entry"; +import { TaskChatLaunchError } from "./components/task-chat-launch-error"; import { UserCommentRunBadge } from "./components/user-comment-run-badge"; import { buildCommentTurnContext, type CommentTurnContext } from "./turn-context"; import { groupSessionsForTimeline, groupSortKey, type SessionGroup } from "./session-groups"; @@ -36,6 +38,8 @@ import { mergeChatEntries, type ChatEntry, } from "./chat-entries"; +import type { TaskRepository } from "@/lib/types/http"; +import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; import { useTranslation } from "react-i18next"; import { DecisionTimelineEntry, TimelineEntry } from "./task-chat-timeline-entries"; @@ -47,6 +51,7 @@ const PROMPT_INSERTED_MESSAGE_KEY = "task:enhancedPromptInserted"; type TaskChatProps = { taskId: string; + workspaceId?: string; comments: TaskComment[]; timeline?: TimelineEvent[]; sessions?: TaskSession[]; @@ -58,6 +63,8 @@ type TaskChatProps = { onCommentsChanged?: () => void; taskTitle?: string; taskDescription?: string; + statusSummary?: TaskStatusSummary | null; + repositories?: TaskRepository[]; }; function partitionGroups(groups: SessionGroup[]): { @@ -516,7 +523,17 @@ function useCommentHashScroll(comments: TaskComment[]): void { }, [comments]); } -function ChatEntries({ taskId, entries }: { taskId: string; entries: ChatEntry[] }) { +function ChatEntries({ + taskId, + workspaceId, + repositories, + entries, +}: { + taskId: string; + workspaceId: string; + repositories?: TaskRepository[]; + entries: ChatEntry[]; +}) { return ( <> {entries.map((entry) => { @@ -538,7 +555,15 @@ function ChatEntries({ taskId, entries }: { taskId: string; entries: ChatEntry[] return ; } if (entry.kind === "error") { - return ; + return ( + + ); } // Session entries are intentionally not rendered in the Chat // tab: every agent message below already shows the same agent @@ -553,6 +578,7 @@ function ChatEntries({ taskId, entries }: { taskId: string; entries: ChatEntry[] export function TaskChat({ taskId, + workspaceId = "", comments, timeline = [], sessions = [], @@ -564,6 +590,8 @@ export function TaskChat({ onCommentsChanged, taskTitle, taskDescription, + statusSummary, + repositories, }: TaskChatProps) { const { t } = useTranslation(); const [showOlder, setShowOlder] = useState(false); @@ -620,11 +648,23 @@ export function TaskChat({ {t("task:showOlderSessions", { count: olderGroups.length })} )} + {isEmpty ? (

{t("task:noCommentsYet")}

) : (
- +
)} {!readOnly && ( diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index be1c6f18c0..837cdbade0 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -37,6 +37,9 @@ import { useAppStore } from "@/components/state-provider"; import { getSessionWorkspacePath } from "@/lib/session-workspace-path"; import { routePanelMouseDown } from "./chat/route-panel-mouse-down"; import { useTranslation } from "react-i18next"; +import { TaskChatLaunchError } from "./simple/components/task-chat-launch-error"; +import { useTaskLaunchErrorContext } from "./task-launch-error-context"; +import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary"; /** * Cap on how many extra pages the last-prompt background lookup will fetch @@ -355,6 +358,11 @@ export const TaskChatPanel = memo(function TaskChatPanel({ }: TaskChatPanelProps) { const isArchived = useIsTaskArchived(); const chatInputRef = useRef(null); + const launchErrorContext = useTaskLaunchErrorContext(); + const launchStatusSummary = useTaskStatusSummary( + launchErrorContext?.taskId, + launchErrorContext?.statusSummary, + ); useSettingsData(true); const panelState = useChatPanelState({ @@ -531,6 +539,15 @@ export const TaskChatPanel = memo(function TaskChatPanel({ className="outline-none" > + {launchErrorContext && ( + + )} Promise; +}; + +/** Responsive branch picker used by the task-scoped recovery card. */ +export function TaskLaunchBranchPicker({ + workspaceId, + repositories, + taskRepositoryId, + currentBase, + trigger, + onSelect, +}: TaskLaunchBranchPickerProps) { + const { t } = useTranslation(); + const { isMobile } = useResponsiveBreakpoint(); + const [repository] = repositories?.filter((candidate) => candidate.id === taskRepositoryId) ?? []; + const source = repository + ? { + kind: "id" as const, + workspaceId, + repositoryId: repositoryId(repository.repository_id), + } + : null; + const [open, setOpen] = useState(false); + const { branches, isLoading: isLoadingBranches } = useBranches(source, open); + const handleSelect = async (branch: string) => { + setOpen(false); + await onSelect(branch); + }; + + if (isMobile) { + return ( + <> + setOpen(true)}>{trigger} + +
+ void handleSelect(branch)} + remoteOnly + testIdPrefix="task-launch-branch-picker" + /> +
+
+ + ); + } + + return ( + + {trigger} + event.preventDefault()} + > + void handleSelect(branch)} + remoteOnly + testIdPrefix="task-launch-branch-picker" + /> + + + ); +} diff --git a/apps/web/components/task/task-launch-error-context.test.tsx b/apps/web/components/task/task-launch-error-context.test.tsx new file mode 100644 index 0000000000..0ca4c9dcb5 --- /dev/null +++ b/apps/web/components/task/task-launch-error-context.test.tsx @@ -0,0 +1,69 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; +import { TaskLaunchErrorProvider, useTaskLaunchErrorContext } from "./task-launch-error-context"; + +const { toastMock } = vi.hoisted(() => ({ + toastMock: vi.fn(), +})); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: toastMock }), +})); +vi.mock("@/lib/i18n", () => ({ t: (key: string) => key })); + +const initialSummary: TaskStatusSummary = { + revision: 1, + updated_at: "2026-08-20T00:00:00Z", + active_error: { + stamp: "initial", + occurred_at: "2026-08-20T00:00:00Z", + preview: "initial error", + category: "base_branch_missing", + }, +}; + +function SummaryStamp() { + const context = useTaskLaunchErrorContext(); + return ( + {context?.statusSummary?.active_error?.stamp ?? "none"} + ); +} + +function renderProvider(statusSummary: TaskStatusSummary) { + return render( + + + , + ); +} + +afterEach(() => { + cleanup(); +}); + +beforeEach(() => { + toastMock.mockReset(); +}); + +describe("TaskLaunchErrorProvider", () => { + it.each(["provider_auth_required", "model_capacity"])( + "does not toast for ordinary active error category %s", + (category) => { + renderProvider({ + ...initialSummary, + active_error: { ...initialSummary.active_error!, category, stamp: `ordinary-${category}` }, + }); + + expect(toastMock).not.toHaveBeenCalled(); + }, + ); + + it("keeps the hydrated status summary as the initial context value", () => { + renderProvider(initialSummary); + + expect(screen.getByTestId("summary-stamp").textContent).toBe("initial"); + }); +}); diff --git a/apps/web/components/task/task-launch-error-context.tsx b/apps/web/components/task/task-launch-error-context.tsx new file mode 100644 index 0000000000..71748d8d3a --- /dev/null +++ b/apps/web/components/task/task-launch-error-context.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { createContext, useContext, useEffect, useMemo, useRef, type ReactNode } from "react"; +import { useToast } from "@/components/toast-provider"; +import { t } from "@/lib/i18n"; +import { getSessionStorage, setSessionStorage } from "@/lib/local-storage"; +import type { TaskRepository } from "@/lib/types/http"; +import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; +import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary"; +import { isTypedTaskLaunchError } from "./simple/components/task-launch-error-entry"; + +// i18n-exempt: persisted browser storage key, not user-facing copy. +const LAUNCH_ERROR_TOASTS_KEY = "kandev.task-launch-error-toasts"; + +export type TaskLaunchErrorContextValue = { + taskId: string; + workspaceId: string; + statusSummary?: TaskStatusSummary | null; + repositories?: TaskRepository[]; +}; + +const TaskLaunchErrorContext = createContext(null); + +export function TaskLaunchErrorProvider({ + value, + children, +}: { + value: TaskLaunchErrorContextValue; + children: ReactNode; +}) { + const { toast } = useToast(); + const shownToastStampsRef = useRef(new Set()); + const statusSummary = useTaskStatusSummary(value.taskId, value.statusSummary); + + useEffect(() => { + const activeError = statusSummary?.active_error; + if (!isTypedTaskLaunchError(activeError)) return; + + const localStamp = `${value.taskId}:${activeError.stamp}`; + if (shownToastStampsRef.current.has(localStamp)) return; + const shownByTask = getSessionStorage>(LAUNCH_ERROR_TOASTS_KEY, {}); + if ((shownByTask[value.taskId] ?? []).includes(activeError.stamp)) { + shownToastStampsRef.current.add(localStamp); + return; + } + + shownToastStampsRef.current.add(localStamp); + setSessionStorage(LAUNCH_ERROR_TOASTS_KEY, { + ...shownByTask, + [value.taskId]: [...(shownByTask[value.taskId] ?? []), activeError.stamp].slice(-20), + }); + toast({ + title: t("task:taskFailedToStart"), + description: t("task:launchFailedSeeDetails"), + variant: "error", + }); + }, [ + statusSummary?.active_error?.category, + statusSummary?.active_error?.stamp, + toast, + value.taskId, + ]); + + const contextValue = useMemo(() => ({ ...value, statusSummary }), [statusSummary, value]); + return ( + + {children} + + ); +} + +export function useTaskLaunchErrorContext(): TaskLaunchErrorContextValue | null { + return useContext(TaskLaunchErrorContext); +} diff --git a/apps/web/components/task/task-layout.tsx b/apps/web/components/task/task-layout.tsx index 281216bec6..afcb5f0015 100644 --- a/apps/web/components/task/task-layout.tsx +++ b/apps/web/components/task/task-layout.tsx @@ -7,6 +7,9 @@ import { SessionMobileLayout, SessionTabletLayout } from "./mobile"; import type { Repository, RepositoryScript } from "@/lib/types/http"; import type { Terminal } from "@/hooks/domains/session/use-terminals"; import type { Layout } from "react-resizable-panels"; +import { isTypedTaskLaunchError } from "./simple/components/task-launch-error-entry"; +import { TaskChatLaunchError } from "./simple/components/task-chat-launch-error"; +import { useTaskLaunchErrorContext } from "./task-launch-error-context"; // Re-export for backwards compatibility export type { SelectedDiff } from "@/hooks/use-session-layout-state"; @@ -61,6 +64,25 @@ export const TaskLayout = memo(function TaskLayout({ isArchived, }: TaskLayoutProps) { const { isMobile, usesDesktopWorkbench, isFullDesktop } = useResponsiveBreakpoint(); + const launchErrorContext = useTaskLaunchErrorContext(); + const activeLaunchError = launchErrorContext?.statusSummary?.active_error; + + if (launchErrorContext && !sessionId && isTypedTaskLaunchError(activeLaunchError)) { + return ( +
+ +
+ ); + } // Mobile layout if (isMobile) { diff --git a/apps/web/components/task/task-page-inner.tsx b/apps/web/components/task/task-page-inner.tsx index f75462d1a8..ae37064574 100644 --- a/apps/web/components/task/task-page-inner.tsx +++ b/apps/web/components/task/task-page-inner.tsx @@ -17,6 +17,7 @@ import { TaskPRShortcut } from "@/components/task/task-pr-shortcut"; import { useEmbeddedVscodeSupport } from "@/components/task/task-page-editor-capability"; import { VcsDialogsProvider } from "@/components/vcs/vcs-dialogs"; import { PortForwardingVisibilityProvider } from "@/components/task/port-forwarding-visibility-provider"; +import { TaskLaunchErrorProvider } from "@/components/task/task-launch-error-context"; import { buildDebugEntries, buildArchivedValue, @@ -281,6 +282,7 @@ export function TaskPageInner(props: TaskPageInnerProps) { const { effectiveSessionId, task, merged, sessionPanel, archivedValue, isMobile, ensureSession } = props; const { taskProps, debugEntries, topBarProps, layoutProps } = useTaskPageDerivedProps(props); + if (!task) return null; return ( @@ -317,7 +319,16 @@ export function TaskPageInner(props: TaskPageInnerProps) { /> )} - + + + diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts index 5bc5db957d..4f2e5257a2 100644 --- a/apps/web/e2e/fixtures/test-base.ts +++ b/apps/web/e2e/fixtures/test-base.ts @@ -379,6 +379,7 @@ export const test = backendFixture.extend< await use(); } finally { await apiClient.clearGitLabRepositoryRemote(seedData.repositoryId).catch(() => undefined); + restoreSeedRepositoryOrigin(seedData); } }, { auto: true }, @@ -387,14 +388,49 @@ export const test = backendFixture.extend< /** * Restores the fixture's offline origin after a GitLab E2E cleanup removes it. - * This lets focused tests distinguish a deleted remote ref from a transport error. + * Refresh the remote-tracking refs because branch recovery must offer remote + * branches, not only the local branch that remains checked out. */ export function restoreSeedRepositoryOrigin(seedData: SeedData) { const baseArgs = ["-C", seedData.repositoryPath, "remote"]; try { - execFileSync("git", [...baseArgs, "set-url", "origin", seedData.repositoryRemoteURL]); + execFileSync("git", [...baseArgs, "set-url", "origin", seedData.repositoryRemoteURL], { + stdio: "ignore", + }); } catch { - execFileSync("git", [...baseArgs, "add", "origin", seedData.repositoryRemoteURL]); + execFileSync("git", [...baseArgs, "add", "origin", seedData.repositoryRemoteURL], { + stdio: "ignore", + }); + } + execFileSync("git", ["-C", seedData.repositoryPath, "fetch", "--no-tags", "origin"], { + stdio: "ignore", + }); +} + +/** Points the seed repository at an empty remote whose HEAD cannot resolve. */ +export function pointSeedRepositoryAtUnresolvedOrigin(seedData: SeedData, tmpDir: string) { + const remoteDir = path.join( + tmpDir, + "repos", + `e2e-unresolved-remote-${Date.now()}-${process.pid}.git`, + ); + fs.mkdirSync(path.dirname(remoteDir), { recursive: true }); + execFileSync("git", ["init", "--bare", "--initial-branch=main", remoteDir], { + env: makeGitEnv(tmpDir), + stdio: "ignore", + }); + try { + execFileSync( + "git", + ["-C", seedData.repositoryPath, "remote", "set-url", "origin", `file://${remoteDir}`], + { env: makeGitEnv(tmpDir), stdio: "ignore" }, + ); + } catch { + execFileSync( + "git", + ["-C", seedData.repositoryPath, "remote", "add", "origin", `file://${remoteDir}`], + { env: makeGitEnv(tmpDir), stdio: "ignore" }, + ); } } diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index bb2e9a3b07..7a384a5462 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -882,6 +882,7 @@ export class ApiClient { async updateRepository( repositoryId: string, updates: { + default_branch?: string; provider?: string; provider_repo_id?: string; provider_host?: string; diff --git a/apps/web/e2e/tests/pr/mobile-pr-watcher-missing-branch.spec.ts b/apps/web/e2e/tests/pr/mobile-pr-watcher-missing-branch.spec.ts index e466911c21..5a287ab9a0 100644 --- a/apps/web/e2e/tests/pr/mobile-pr-watcher-missing-branch.spec.ts +++ b/apps/web/e2e/tests/pr/mobile-pr-watcher-missing-branch.spec.ts @@ -4,7 +4,7 @@ import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-asserti import { SessionPage } from "../../pages/session-page"; test.describe("mobile PR watcher missing branch", () => { - test("keeps the recovery summary and task actions reachable", async ({ + test("keeps the typed recovery card reachable", async ({ testPage, apiClient, seedData, @@ -20,8 +20,8 @@ test.describe("mobile PR watcher missing branch", () => { await apiClient.mockGitHubAddPRs([ { number: 1000, - title: "Already merged mobile feature", - state: "closed", + title: "Open mobile feature with deleted branch", + state: "open", head_branch: prBranch, base_branch: "main", author_login: "test-user", @@ -32,7 +32,7 @@ test.describe("mobile PR watcher missing branch", () => { const task = await apiClient.createTaskWithAgent( seedData.workspaceId, - "PR #1000: Already merged mobile feature", + "PR #1000: Open mobile feature with deleted branch", seedData.agentProfileId, { workflow_id: seedData.workflowId, @@ -61,31 +61,25 @@ test.describe("mobile PR watcher missing branch", () => { repo: "testrepo", pr_number: 1000, pr_url: "https://github.com/testorg/testrepo/pull/1000", - pr_title: "Already merged mobile feature", + pr_title: "Open mobile feature with deleted branch", head_branch: prBranch, base_branch: "main", author_login: "test-user", - state: "closed", + state: "open", }); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); + await session.waitForLoad(); const chat = session.activeChat(); - const recovery = chat.getByTestId("missing-branch-recovery"); + const recovery = chat.getByTestId("task-launch-error-entry"); await expect(recovery).toHaveCount(1, { timeout: 30_000 }); - await expect( - recovery.getByRole("heading", { name: "Branch is no longer available" }), - ).toBeVisible(); - await expect(recovery).toContainText("***"); + await expect(recovery).toContainText("The selected base branch is not available."); await expect(recovery).not.toContainText(prBranch); - await expect( - chat.getByRole("status", { name: /Session failed|Environment setup failed/i }), - ).toHaveCount(0); - - const archiveButton = recovery.getByTestId("missing-branch-archive-button"); - const deleteButton = recovery.getByTestId("missing-branch-delete-button"); - for (const button of [archiveButton, deleteButton]) { + const actionButtons = recovery.locator("button[data-testid^='task-launch-']"); + await expect(actionButtons).not.toHaveCount(0); + for (const button of await actionButtons.all()) { await expect(button).toBeVisible(); await expect(button).toBeInViewport(); const box = await button.boundingBox(); @@ -93,19 +87,7 @@ test.describe("mobile PR watcher missing branch", () => { expect(box!.height).toBeGreaterThanOrEqual(44); } - const technicalDetails = recovery.locator("details"); - const technicalOutput = technicalDetails.locator("pre"); - await expect(technicalDetails).not.toHaveAttribute("open"); - await expect(technicalOutput).not.toBeVisible(); - const disclosure = recovery.getByText("Technical details", { exact: true }); - await disclosure.tap(); - await expect(technicalOutput).toBeVisible(); - await expect(technicalOutput).toContainText("couldn't find remote ref pull/1000/head"); - const disclosureBox = await disclosure.boundingBox(); - expect(disclosureBox).not.toBeNull(); - expect(disclosureBox!.height).toBeGreaterThanOrEqual(44); - - await assertNoDocumentHorizontalOverflow(testPage, "missing branch recovery"); + await assertNoDocumentHorizontalOverflow(testPage, "typed launch recovery"); await testPage.screenshot({ path: testInfo.outputPath("missing-pr-branch-mobile.png"), fullPage: true, diff --git a/apps/web/e2e/tests/pr/pr-watcher-missing-branch.spec.ts b/apps/web/e2e/tests/pr/pr-watcher-missing-branch.spec.ts index 9d2a5fc853..44467fabb7 100644 --- a/apps/web/e2e/tests/pr/pr-watcher-missing-branch.spec.ts +++ b/apps/web/e2e/tests/pr/pr-watcher-missing-branch.spec.ts @@ -2,19 +2,7 @@ import { restoreSeedRepositoryOrigin, test, expect } from "../../fixtures/test-b import { SessionPage } from "../../pages/session-page"; test.describe("PR watcher missing branch", () => { - /** - * When a task is created from a PR watcher and the PR's head branch has - * already been deleted (merged PR), clicking the task should show a - * user-friendly error in the chat with options to archive or delete. - * - * Setup: - * - Create task with checkout_branch pointing to a non-existent branch - * (simulates what the PR watcher creates when a PR is found) - * - Associate a mock PR with the task - * - Navigate to the task — frontend auto-prepares the session - * - Environment preparation fails because the branch doesn't exist - * - Assert the guidance message appears in chat - */ + /** A deleted PR branch must use the typed launch-error surface. */ test("shows one focused recovery panel when PR branch is deleted", async ({ testPage, apiClient, @@ -30,12 +18,12 @@ test.describe("PR watcher missing branch", () => { const prBranch = "feature/already-merged-and-deleted"; - // Mock the PR that was already merged (branch deleted on remote) + // Mock an open PR whose head branch was deleted on the remote. await apiClient.mockGitHubAddPRs([ { number: 999, - title: "Already merged feature", - state: "closed", + title: "Open feature with deleted branch", + state: "open", head_branch: prBranch, base_branch: "main", author_login: "test-user", @@ -48,7 +36,7 @@ test.describe("PR watcher missing branch", () => { // no longer exists on remote (PR was merged, branch deleted). const task = await apiClient.createTaskWithAgent( seedData.workspaceId, - "PR #999: Already merged feature", + "PR #999: Open feature with deleted branch", seedData.agentProfileId, { workflow_id: seedData.workflowId, @@ -78,52 +66,39 @@ test.describe("PR watcher missing branch", () => { repo: "testrepo", pr_number: 999, pr_url: "https://github.com/testorg/testrepo/pull/999", - pr_title: "Already merged feature", + pr_title: "Open feature with deleted branch", head_branch: prBranch, base_branch: "main", author_login: "test-user", - state: "closed", + state: "open", }); + await expect + .poll( + async () => { + const { tasks } = await apiClient.listTasks(seedData.workspaceId); + return tasks.find((candidate) => candidate.id === task.id)?.status_summary?.active_error + ?.category; + }, + { timeout: 60_000, message: "waiting for the durable PR launch-error projection" }, + ) + .toBe("base_branch_missing"); + // --- Navigate to the task session view --- await testPage.goto(`/t/${task.id}`); await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); const session = new SessionPage(testPage); + await session.waitForLoad(); - // --- Assert the primary recovery message appears once in chat --- + // --- Assert the bounded launch-error card appears once in chat --- const chat = session.activeChat(); - const recovery = chat.getByTestId("missing-branch-recovery"); + const recovery = chat.getByTestId("task-launch-error-entry"); await expect(recovery).toHaveCount(1, { timeout: 30_000 }); - await expect( - recovery.getByRole("heading", { name: "Branch is no longer available" }), - ).toBeVisible(); - await expect(recovery).toContainText("***"); + await expect(recovery).toContainText("The selected base branch is not available."); await expect(recovery).not.toContainText(prBranch); - await expect(recovery).toContainText("merged or deleted"); - - // The actionable recovery panel replaces the generic failed-agent banner. - await expect( - chat.getByRole("status", { name: /Session failed|Environment setup failed/i }), - ).toHaveCount(0); - - // Diagnostics stay secondary until the user explicitly expands them. - const technicalDetails = recovery.locator("details"); - const technicalOutput = technicalDetails.locator("pre"); - await expect(technicalDetails).not.toHaveAttribute("open"); - await expect(technicalOutput).not.toBeVisible(); - await recovery.getByText("Technical details", { exact: true }).click(); - await expect(technicalDetails).toHaveAttribute("open", ""); - await expect(technicalOutput).toBeVisible(); - await expect(technicalOutput).toContainText("couldn't find remote ref pull/999/head"); - - // Verify the action buttons are present - await expect(recovery.getByTestId("missing-branch-archive-button")).toBeVisible({ - timeout: 5_000, - }); - await expect(recovery.getByTestId("missing-branch-delete-button")).toBeVisible({ - timeout: 5_000, - }); + await expect(recovery.getByTestId("task-launch-archive-button")).toHaveCount(0); + await expect(recovery.getByTestId("task-launch-delete-button")).toHaveCount(0); await testPage.screenshot({ path: testInfo.outputPath("missing-pr-branch-desktop.png"), @@ -135,14 +110,6 @@ test.describe("PR watcher missing branch", () => { const failedSession = sessions.find((s) => s.state === "FAILED"); expect(failedSession).toBeTruthy(); - // Verify the guidance message metadata via API - const { messages } = await apiClient.listSessionMessages(failedSession!.id); - const guidanceMsg = messages.find( - (m: Record) => - (m.metadata as Record)?.failure_kind === "missing_pr_branch", - ); - expect(guidanceMsg).toBeTruthy(); - expect((guidanceMsg as Record).content).toContain("***"); - expect((guidanceMsg as Record).content).not.toContain(prBranch); + expect(failedSession!.metadata?.last_agent_error).toBeTruthy(); }); }); diff --git a/apps/web/e2e/tests/task/launch-failure-recovery.spec.ts b/apps/web/e2e/tests/task/launch-failure-recovery.spec.ts new file mode 100644 index 0000000000..2285fc4d55 --- /dev/null +++ b/apps/web/e2e/tests/task/launch-failure-recovery.spec.ts @@ -0,0 +1,279 @@ +import { + pointSeedRepositoryAtUnresolvedOrigin, + restoreSeedRepositoryOrigin, + test, + expect, +} from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import type { ApiClient } from "../../helpers/api-client"; +import { SessionPage } from "../../pages/session-page"; + +type LaunchError = { + session_id?: string; + task_repository_id?: string; + stamp?: string; + category?: string; + recovery_actions?: string[]; + preview?: string; +}; + +async function taskLaunchError( + apiClient: ApiClient, + workspaceId: string, + taskId: string, +): Promise { + const { tasks } = await apiClient.listTasks(workspaceId); + const task = tasks.find((candidate: { id: string }) => candidate.id === taskId); + return (task?.status_summary?.active_error as LaunchError | null | undefined) ?? null; +} + +async function waitForTaskLaunchError( + apiClient: ApiClient, + workspaceId: string, + taskId: string, + category?: string, +): Promise { + await expect + .poll( + async () => { + const error = await taskLaunchError(apiClient, workspaceId, taskId); + return Boolean(error?.stamp && (!category || error.category === category)); + }, + { timeout: 60_000, message: `waiting for typed launch error on ${taskId}` }, + ) + .toBe(true); + const error = await taskLaunchError(apiClient, workspaceId, taskId); + if (!error?.stamp) throw new Error(`task ${taskId} has no typed launch error`); + return error; +} + +async function waitForLaunchErrorCleared( + apiClient: ApiClient, + workspaceId: string, + taskId: string, +): Promise { + await expect + .poll(async () => (await taskLaunchError(apiClient, workspaceId, taskId)) === null, { + timeout: 60_000, + message: `waiting for launch error ${taskId} to clear`, + }) + .toBe(true); +} + +async function recoveryWorkflow(apiClient: ApiClient, workspaceId: string, name: string) { + const workflow = await apiClient.createWorkflow(workspaceId, name); + const waiting = await apiClient.createWorkflowStep(workflow.id, "Waiting", 0); + const review = await apiClient.createWorkflowStep(workflow.id, "Review", 1); + const done = await apiClient.createWorkflowStep(workflow.id, "Done", 2); + await apiClient.updateWorkflowStep(review.id, { + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + return { workflow, waiting, review, done }; +} + +test.describe("task launch failure recovery", () => { + test("gates a closed PR before session creation and persists Mark review done", async ({ + testPage, + apiClient, + seedData, + }, testInfo) => { + test.setTimeout(120_000); + + const { workflow, waiting, review, done } = await recoveryWorkflow( + apiClient, + seedData.workspaceId, + `Launch recovery gate ${Date.now()}`, + ); + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + await apiClient.mockGitHubAddPRs([ + { + number: 1201, + title: "Merged launch fixture", + state: "closed", + head_branch: "feature/merged-launch-fixture", + base_branch: "main", + author_login: "test-user", + repo_owner: "testorg", + repo_name: "testrepo", + }, + ]); + + const task = await apiClient.createTask(seedData.workspaceId, "Closed PR launch fixture", { + workflow_id: workflow.id, + workflow_step_id: waiting.id, + executor_profile_id: seedData.worktreeExecutorProfileId, + repositories: [ + { + repository_id: seedData.repositoryId, + base_branch: "main", + checkout_branch: "feature/merged-launch-fixture", + pr_number: 1201, + }, + ], + metadata: { agent_profile_id: seedData.agentProfileId }, + }); + await apiClient.mockGitHubAssociateTaskPR({ + task_id: task.id, + repository_id: seedData.repositoryId, + owner: "testorg", + repo: "testrepo", + pr_number: 1201, + pr_url: "https://github.com/testorg/testrepo/pull/1201", + pr_title: "Merged launch fixture", + head_branch: "feature/merged-launch-fixture", + base_branch: "main", + author_login: "test-user", + state: "closed", + }); + + await apiClient.moveTask(task.id, workflow.id, review.id); + const launchError = await waitForTaskLaunchError( + apiClient, + seedData.workspaceId, + task.id, + "pr_already_closed", + ); + expect(launchError.recovery_actions).toEqual(["mark_review_done"]); + + const sessionsBeforeOpen = await apiClient.listTaskSessions(task.id); + expect(sessionsBeforeOpen.sessions).toHaveLength(0); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + const card = testPage.getByTestId("task-launch-error-entry"); + await expect(card).toHaveCount(1, { timeout: 30_000 }); + await expect(card).toContainText("The task is linked to a closed pull request."); + + await testPage.reload(); + await session.waitForLoad(); + await expect(testPage.getByTestId("task-launch-error-entry")).toHaveCount(1); + await testPage.getByTestId("task-launch-mark_review_done-button").click(); + + await expect + .poll(async () => (await apiClient.getTask(task.id)).workflow_step_id, { + timeout: 30_000, + message: "waiting for the task to move to Done", + }) + .toBe(done.id); + await waitForLaunchErrorCleared(apiClient, seedData.workspaceId, task.id); + expect((await apiClient.listTaskSessions(task.id)).sessions).toHaveLength(0); + + await testPage.screenshot({ + path: testInfo.outputPath("closed-pr-launch-gate-desktop.png"), + fullPage: true, + }); + }); + + test("recovers an exact task-repository row and persists the self-heal", async ({ + testPage, + apiClient, + seedData, + backend, + }, testInfo) => { + test.setTimeout(150_000); + + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + `Missing base recovery ${Date.now()}`, + ); + const waiting = await apiClient.createWorkflowStep(workflow.id, "Waiting", 0); + const review = await apiClient.createWorkflowStep(workflow.id, "Review", 1); + await apiClient.updateWorkflowStep(review.id, { + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + + const task = await apiClient.createTask( + seedData.workspaceId, + "Missing base branch recovery fixture", + { + description: "/e2e:simple-message", + workflow_id: workflow.id, + workflow_step_id: waiting.id, + agent_profile_id: seedData.agentProfileId, + executor_profile_id: seedData.worktreeExecutorProfileId, + repositories: [ + { + repository_id: seedData.repositoryId, + base_branch: "branch-that-no-longer-exists", + }, + ], + }, + ); + const storedTask = await apiClient.getTask(task.id); + const taskRepository = storedTask.repositories?.[0]; + if (!taskRepository) throw new Error("launch fixture did not create a task repository row"); + + await apiClient.updateRepository(seedData.repositoryId, { + default_branch: "default-branch-that-no-longer-exists", + }); + pointSeedRepositoryAtUnresolvedOrigin(seedData, backend.tmpDir); + + try { + await apiClient.moveTask(task.id, workflow.id, review.id); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + const launchError = await waitForTaskLaunchError(apiClient, seedData.workspaceId, task.id); + expect(launchError.task_repository_id).toBe(taskRepository.id); + expect(launchError.recovery_actions).toEqual(["retry_default", "pick_base_branch"]); + + const pointerToast = testPage + .getByTestId("toast-message") + .filter({ hasText: "The task launch failed. Open the task details for recovery actions." }); + await expect(pointerToast).toBeVisible({ timeout: 30_000 }); + await expect(pointerToast).not.toContainText("branch-that-no-longer-exists"); + + const card = testPage.getByTestId("task-launch-error-entry"); + await expect(card).toHaveCount(1, { timeout: 30_000 }); + await expect(card).toContainText("The selected base branch is not available."); + await expect(card).not.toContainText("branch-that-no-longer-exists"); + + restoreSeedRepositoryOrigin(seedData); + await testPage.reload(); + await session.waitForLoad(); + await expect(testPage.getByTestId("task-launch-error-entry")).toHaveCount(1); + + await testPage.getByTestId("task-launch-pick_base_branch-button").click(); + await expect(testPage.getByTestId("task-launch-branch-picker-option-main")).toBeVisible({ + timeout: 30_000, + }); + await testPage.getByTestId("task-launch-branch-picker-option-main").click(); + await expect(testPage.getByTestId("task-launch-branch-picker-option-main")).toHaveCount(0); + + await expect + .poll(async () => (await apiClient.getTask(task.id)).repositories?.[0]?.base_branch, { + timeout: 60_000, + message: "waiting for the exact task repository base to self-heal", + }) + .toBe("main"); + await waitForLaunchErrorCleared(apiClient, seedData.workspaceId, task.id); + + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + return sessions.some((item) => + ["RUNNING", "WAITING_FOR_INPUT", "IDLE", "COMPLETED"].includes(item.state), + ); + }, + { timeout: 60_000, message: "waiting for the recovered session to launch" }, + ) + .toBe(true); + + await expect( + testPage.getByTestId("toast-message").filter({ hasText: "Recovery could not start" }), + ).toHaveCount(0); + + await assertNoDocumentHorizontalOverflow(testPage, "desktop launch recovery"); + await testPage.screenshot({ + path: testInfo.outputPath("missing-base-recovery-desktop.png"), + fullPage: true, + }); + } finally { + restoreSeedRepositoryOrigin(seedData); + await apiClient.updateRepository(seedData.repositoryId, { default_branch: "main" }); + } + }); +}); diff --git a/apps/web/e2e/tests/task/mobile-launch-failure-recovery.spec.ts b/apps/web/e2e/tests/task/mobile-launch-failure-recovery.spec.ts new file mode 100644 index 0000000000..5fb1bd74a0 --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-launch-failure-recovery.spec.ts @@ -0,0 +1,142 @@ +// Filename starts with "mobile-" so this runs in the mobile-chrome project. +import { + pointSeedRepositoryAtUnresolvedOrigin, + restoreSeedRepositoryOrigin, + test, + expect, +} from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import type { ApiClient } from "../../helpers/api-client"; +import { SessionPage } from "../../pages/session-page"; + +async function waitForLaunchError(apiClient: ApiClient, workspaceId: string, taskId: string) { + await expect + .poll( + async () => { + const { tasks } = await apiClient.listTasks(workspaceId); + const error = tasks.find((candidate) => candidate.id === taskId)?.status_summary + ?.active_error; + return Boolean(error?.stamp && error.category === "base_branch_missing"); + }, + { timeout: 60_000, message: "waiting for the mobile launch-error projection" }, + ) + .toBe(true); +} + +test.describe("mobile task launch failure recovery", () => { + test("uses the phone branch sheet and keeps recovery controls reachable", async ({ + testPage, + apiClient, + seedData, + backend, + }, testInfo) => { + test.setTimeout(150_000); + + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + `Mobile missing base recovery ${Date.now()}`, + ); + const waiting = await apiClient.createWorkflowStep(workflow.id, "Waiting", 0); + const review = await apiClient.createWorkflowStep(workflow.id, "Review", 1); + await apiClient.updateWorkflowStep(review.id, { + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + + const task = await apiClient.createTask( + seedData.workspaceId, + "Mobile missing base branch recovery fixture", + { + description: "/e2e:simple-message", + workflow_id: workflow.id, + workflow_step_id: waiting.id, + agent_profile_id: seedData.agentProfileId, + executor_profile_id: seedData.worktreeExecutorProfileId, + repositories: [ + { + repository_id: seedData.repositoryId, + base_branch: "mobile-branch-that-no-longer-exists", + }, + ], + }, + ); + const storedTask = await apiClient.getTask(task.id); + const taskRepository = storedTask.repositories?.[0]; + if (!taskRepository) throw new Error("mobile fixture did not create a task repository row"); + + await apiClient.updateRepository(seedData.repositoryId, { + default_branch: "mobile-default-branch-that-no-longer-exists", + }); + pointSeedRepositoryAtUnresolvedOrigin(seedData, backend.tmpDir); + + try { + await apiClient.moveTask(task.id, workflow.id, review.id); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await waitForLaunchError(apiClient, seedData.workspaceId, task.id); + + const card = testPage.getByTestId("task-launch-error-entry"); + await expect(card).toHaveCount(1, { timeout: 30_000 }); + const actionButtons = card.locator("button[data-testid^='task-launch-']"); + await expect(actionButtons).not.toHaveCount(0); + for (const button of await actionButtons.all()) { + await expect(button).toBeVisible(); + await expect(button).toBeInViewport(); + const box = await button.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).toBeGreaterThanOrEqual(44); + } + + restoreSeedRepositoryOrigin(seedData); + await testPage.reload(); + await session.waitForLoad(); + await testPage.getByTestId("task-launch-pick_base_branch-button").tap(); + await expect(testPage.getByTestId("task-launch-branch-picker-mobile")).toBeVisible({ + timeout: 30_000, + }); + const pickerScroll = testPage.getByTestId("task-launch-branch-picker-scroll"); + await expect(pickerScroll).toBeVisible(); + await expect + .poll(async () => pickerScroll.evaluate((node) => getComputedStyle(node).overflowY)) + .toBe("auto"); + + const branchOption = testPage.getByTestId("task-launch-branch-picker-option-main"); + await expect(branchOption).toBeVisible({ timeout: 30_000 }); + await expect(branchOption).toBeInViewport(); + const optionBox = await branchOption.boundingBox(); + expect(optionBox).not.toBeNull(); + expect(optionBox!.height).toBeGreaterThanOrEqual(44); + await branchOption.tap(); + await expect(testPage.getByTestId("task-launch-branch-picker-mobile")).toHaveCount(0); + + await expect + .poll(async () => (await apiClient.getTask(task.id)).repositories?.[0]?.base_branch, { + timeout: 60_000, + message: "waiting for mobile row-scoped recovery", + }) + .toBe("main"); + await expect + .poll( + async () => { + const { tasks } = await apiClient.listTasks(seedData.workspaceId); + return ( + tasks.find((candidate) => candidate.id === task.id)?.status_summary?.active_error ?? + null + ); + }, + { timeout: 60_000, message: "waiting for the mobile launch error to clear" }, + ) + .toBeNull(); + + expect(taskRepository.id).toBe((await apiClient.getTask(task.id)).repositories?.[0]?.id); + await assertNoDocumentHorizontalOverflow(testPage, "mobile launch recovery"); + await testPage.screenshot({ + path: testInfo.outputPath("missing-base-recovery-mobile.png"), + fullPage: true, + }); + } finally { + restoreSeedRepositoryOrigin(seedData); + await apiClient.updateRepository(seedData.repositoryId, { default_branch: "main" }); + } + }); +}); diff --git a/apps/web/hooks/domains/task/use-task-status-summary.test.ts b/apps/web/hooks/domains/task/use-task-status-summary.test.ts new file mode 100644 index 0000000000..ed906a0090 --- /dev/null +++ b/apps/web/hooks/domains/task/use-task-status-summary.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { collectTaskStatusSummaryCandidates } from "./use-task-status-summary"; + +const summary = (revision: number) => ({ + revision, + updated_at: `2026-08-20T00:00:0${revision}Z`, +}); + +describe("collectTaskStatusSummaryCandidates", () => { + it("collects the task from live kanban, snapshots, and archived lists", () => { + const liveSummary = summary(4); + const candidates = collectTaskStatusSummaryCandidates( + { + kanban: { + tasks: [{ id: "other", statusSummary: summary(1) }], + }, + kanbanMulti: { + snapshots: { + workflow: { tasks: [{ id: "task-1", statusSummary: liveSummary }] }, + }, + }, + sidebarArchivedTasks: { + itemsByWorkspaceId: { + workspace: [{ id: "task-1", statusSummary: summary(3) }], + }, + }, + }, + "task-1", + ); + + expect(candidates).toEqual([liveSummary, expect.objectContaining({ revision: 3 })]); + }); +}); diff --git a/apps/web/hooks/domains/task/use-task-status-summary.ts b/apps/web/hooks/domains/task/use-task-status-summary.ts new file mode 100644 index 0000000000..f7ba277c8e --- /dev/null +++ b/apps/web/hooks/domains/task/use-task-status-summary.ts @@ -0,0 +1,49 @@ +"use client"; + +import { useMemo } from "react"; +import { useOptionalAppStore } from "@/components/state-provider"; +import { selectTaskStatusSummary } from "@/lib/task-status-summary"; +import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; + +type StatusSummaryTask = { + id: string; + statusSummary?: TaskStatusSummary | null; +}; + +export type TaskStatusSummaryState = { + kanban: { tasks: StatusSummaryTask[] }; + kanbanMulti: { snapshots: Record }; + sidebarArchivedTasks: { + itemsByWorkspaceId: Record; + }; +}; + +export function collectTaskStatusSummaryCandidates( + state: TaskStatusSummaryState, + taskId: string, +): Array { + const candidates: Array = []; + const addCandidate = (candidate: StatusSummaryTask) => { + if (candidate.id === taskId) candidates.push(candidate.statusSummary); + }; + state.kanban.tasks.forEach(addCandidate); + Object.values(state.kanbanMulti.snapshots).forEach((snapshot) => + snapshot.tasks.forEach(addCandidate), + ); + Object.values(state.sidebarArchivedTasks.itemsByWorkspaceId).forEach((items) => + items.forEach(addCandidate), + ); + return candidates; +} + +export function useTaskStatusSummary( + taskId: string | null | undefined, + detail: TaskStatusSummary | null | undefined, +): TaskStatusSummary | null | undefined { + const live = useOptionalAppStore((state) => { + if (!taskId) return null; + return selectTaskStatusSummary(undefined, collectTaskStatusSummaryCandidates(state, taskId)); + }, null); + + return useMemo(() => selectTaskStatusSummary(detail, [live]), [detail, live]); +} diff --git a/apps/web/hooks/use-session-failure-toast.ts b/apps/web/hooks/use-session-failure-toast.ts index ef5aa4cbf9..d43004eb89 100644 --- a/apps/web/hooks/use-session-failure-toast.ts +++ b/apps/web/hooks/use-session-failure-toast.ts @@ -33,9 +33,12 @@ export function useSessionFailureToast() { return; } shownRef.current.add(notification.sessionId); + const description = notification.isLaunchFailure + ? t("task:launchFailedSeeDetails") + : notification.message; toast({ title: t("task:taskFailedToStart"), - description: notification.message, + description, variant: "error", }); if (nativeNotifications.isAvailable()) { @@ -74,7 +77,7 @@ export function useSessionFailureToast() { await nativeNotifications.show({ eventId: `session.failed:${notification.sessionId}`, title: t("task:taskFailedToStart"), - body: notification.message, + body: description, taskId: notification.taskId, sessionId: notification.sessionId, }); diff --git a/apps/web/lib/services/session-launch-service.ts b/apps/web/lib/services/session-launch-service.ts index d9cbb87d38..ab7e111a73 100644 --- a/apps/web/lib/services/session-launch-service.ts +++ b/apps/web/lib/services/session-launch-service.ts @@ -64,7 +64,12 @@ export type EnsureSessionResponse = { session_id?: string; state: string; agent_profile_id?: string; - source: "existing_primary" | "existing_newest" | "created_prepare" | "created_start"; + source: + | "existing_primary" + | "existing_newest" + | "created_prepare" + | "created_start" + | "skipped_terminal_pr"; newly_created: boolean; workspace_path?: string; }; diff --git a/apps/web/lib/session-last-agent-error.test.ts b/apps/web/lib/session-last-agent-error.test.ts index 2b3f216a16..6a26f230dc 100644 --- a/apps/web/lib/session-last-agent-error.test.ts +++ b/apps/web/lib/session-last-agent-error.test.ts @@ -51,7 +51,26 @@ describe("readLastAgentError", () => { }); }); +// eslint-disable-next-line max-lines-per-function -- this group preserves the complete metadata compatibility matrix. describe("readLastAgentError optional metadata", () => { + it("reads typed launch recovery fields and keeps action order bounded", () => { + expect( + readLastAgentError({ + last_agent_error: { + message: "base branch is missing", + code: "base_branch_missing", + recovery_actions: ["pick_base_branch", "pick_base_branch", "unknown", "retry_default"], + task_repository_id: "task-repo-1", + stamp: "launch-stamp-1", + }, + }), + ).toMatchObject({ + recoveryActions: ["pick_base_branch", "retry_default"], + taskRepositoryId: "task-repo-1", + stamp: "launch-stamp-1", + }); + }); + it("reads snake_case metadata and keeps occurredAt optional", () => { expect( readLastAgentError({ diff --git a/apps/web/lib/session-last-agent-error.ts b/apps/web/lib/session-last-agent-error.ts index 5f4fad8fd6..ae8e9b89fc 100644 --- a/apps/web/lib/session-last-agent-error.ts +++ b/apps/web/lib/session-last-agent-error.ts @@ -1,4 +1,8 @@ import { getLocalStorage, setLocalStorage } from "@/lib/local-storage"; +import { + normalizeTaskLaunchRecoveryActions, + type TaskLaunchRecoveryAction, +} from "@/lib/types/task-launch-error"; export type LastAgentError = { message: string; @@ -8,6 +12,9 @@ export type LastAgentError = { remediationUrl?: string; code?: string; details?: string; + recoveryActions?: TaskLaunchRecoveryAction[]; + taskRepositoryId?: string; + stamp?: string; dismissedAt?: string; }; @@ -53,13 +60,37 @@ export function readLastAgentError(metadata: Record | null | un if (!message) return null; const dismissedAt = readFirstOptionalString(record, ["dismissed_at", "dismissedAt"]); if (dismissedAt) return null; - return { - message, - occurredAt: readFirstOptionalString(record, ["occurred_at", "occurredAt"]), - agentExecutionId: readFirstOptionalString(record, ["agent_execution_id", "agentExecutionId"]), - remediationUrl: readFirstOptionalString(record, ["remediation_url", "remediationUrl"]), - ...readStructuredFailureMetadata(record), - } satisfies LastAgentError; + return { message, ...readOptionalAgentErrorFields(record) }; +} + +function readOptionalAgentErrorFields( + record: Record, +): Omit { + const result: Omit = {}; + const occurredAt = readFirstOptionalString(record, ["occurred_at", "occurredAt"]); + const agentExecutionId = readFirstOptionalString(record, [ + "agent_execution_id", + "agentExecutionId", + ]); + const remediationUrl = readFirstOptionalString(record, ["remediation_url", "remediationUrl"]); + const recoveryActions = normalizeTaskLaunchRecoveryActions( + record.recovery_actions ?? record.recoveryActions, + ); + const taskRepositoryId = readFirstOptionalString(record, [ + "task_repository_id", + "taskRepositoryId", + ]); + const stamp = readFirstOptionalString(record, ["stamp"]); + const structured = readStructuredFailureMetadata(record); + if (occurredAt) result.occurredAt = occurredAt; + if (agentExecutionId) result.agentExecutionId = agentExecutionId; + if (remediationUrl) result.remediationUrl = remediationUrl; + if (recoveryActions.length > 0) result.recoveryActions = recoveryActions; + if (taskRepositoryId) result.taskRepositoryId = taskRepositoryId; + if (stamp) result.stamp = stamp; + if (structured.code) result.code = structured.code; + if (structured.details) result.details = structured.details; + return result; } function readStructuredFailureMetadata(record: Record) { @@ -89,7 +120,7 @@ function readFirstOptionalString(record: Record, keys: string[] * `last_agent_error` metadata. */ export function lastAgentErrorStamp(error: LastAgentError) { - return `${error.occurredAt ?? ""}:${error.message}`; + return error.stamp ?? `${error.occurredAt ?? ""}:${error.message}`; } function readOptionalString(value: unknown) { diff --git a/apps/web/lib/state/slices/office/office-task-type.ts b/apps/web/lib/state/slices/office/office-task-type.ts index 413be86276..9150aa253a 100644 --- a/apps/web/lib/state/slices/office/office-task-type.ts +++ b/apps/web/lib/state/slices/office/office-task-type.ts @@ -1,4 +1,6 @@ import type { OfficeTaskPriority, OfficeTaskStatus, TaskLabel } from "./types"; +import type { TaskRepository } from "@/lib/types/http"; +import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; export type OfficeTask = { id: string; @@ -29,4 +31,6 @@ export type OfficeTask = { // UI renders a "System" badge for these when the dev toggle reveals // them. isSystem?: boolean; + statusSummary?: TaskStatusSummary | null; + repositories?: TaskRepository[]; }; diff --git a/apps/web/lib/state/slices/office/types.ts b/apps/web/lib/state/slices/office/types.ts index b8e2d1cd1c..a97f8a0a47 100644 --- a/apps/web/lib/state/slices/office/types.ts +++ b/apps/web/lib/state/slices/office/types.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- this file is the canonical Office entity contract. */ // --- Office entity types --- // // Per ADR 0005 Wave E the canonical `AgentProfile`, `AgentRole`, `AgentStatus`, diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index 7387853dbb..5529eb668e 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -173,6 +173,8 @@ export type SessionFailureNotification = { sessionId: string; taskId: string; message: string; + /** Typed launch failures point users to the persistent task card. */ + isLaunchFailure?: boolean; }; export type TaskDeletedNotification = { diff --git a/apps/web/lib/task-status-summary.test.ts b/apps/web/lib/task-status-summary.test.ts index c648df6d7d..1787728a0c 100644 --- a/apps/web/lib/task-status-summary.test.ts +++ b/apps/web/lib/task-status-summary.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { isNewerStatusSummary, pickFreshestStatusSummary } from "./task-status-summary"; +import { + isNewerStatusSummary, + pickFreshestStatusSummary, + selectTaskStatusSummary, + statusSummaryActiveErrorPreview, +} from "./task-status-summary"; import type { TaskStatusSummary } from "./types/task-status-summary"; function summary(revision: number, queuedPromptCount = 0): TaskStatusSummary { @@ -56,3 +61,33 @@ describe("pickFreshestStatusSummary", () => { expect(pickFreshestStatusSummary(next, summary(3, 0))).toBe(next); }); }); + +describe("selectTaskStatusSummary", () => { + it("selects the newest detail or live cache reading", () => { + const detail = summary(4); + const live = summary(7); + expect(selectTaskStatusSummary(detail, [summary(3), live])).toBe(live); + }); + + it("lets a newer live reading clear an older detail error", () => { + const detail = summary(5); + detail.active_error = { + stamp: "launch-error-5", + occurred_at: "2026-08-20T10:00:00Z", + preview: "launch failed", + }; + const live = summary(6); + live.active_error = null; + expect(selectTaskStatusSummary(detail, [live])).toBe(live); + }); + + it("keeps a task-owned active error visible without a session key", () => { + const taskOwned = summary(2); + taskOwned.active_error = { + stamp: "task-error-2", + occurred_at: "2026-08-19T10:00:00Z", + preview: "Choose a base branch", + }; + expect(statusSummaryActiveErrorPreview(taskOwned, {}, {})).toBe("Choose a base branch"); + }); +}); diff --git a/apps/web/lib/task-status-summary.ts b/apps/web/lib/task-status-summary.ts index 4c765b5f90..3aadff7716 100644 --- a/apps/web/lib/task-status-summary.ts +++ b/apps/web/lib/task-status-summary.ts @@ -50,9 +50,18 @@ export function pickFreshestStatusSummary( return next.revision >= current.revision ? next : current; } +/** Select the newest detail/live reading without allowing an HTTP response to regress it. */ +export function selectTaskStatusSummary( + detail: TaskStatusSummary | null | undefined, + live: Array, +): TaskStatusSummary | null | undefined { + return live.reduce((current, candidate) => pickFreshestStatusSummary(candidate, current), detail); +} + /** * Returns the current task-level error only when it has not already been - * acknowledged or dismissed for the same session and stamp. + * acknowledged or dismissed for the same session and stamp. Task-owned + * errors have no session key and stay visible until the backend clears them. */ export function statusSummaryActiveErrorPreview( summary: TaskStatusSummary | null | undefined, @@ -62,8 +71,9 @@ export function statusSummaryActiveErrorPreview( const error = summary?.active_error; if (!error) return null; if ( - acknowledgedAgentErrors?.[error.session_id] === error.stamp || - dismissedAgentErrors?.[error.session_id] === error.stamp + error.session_id && + (acknowledgedAgentErrors?.[error.session_id] === error.stamp || + dismissedAgentErrors?.[error.session_id] === error.stamp) ) { return null; } diff --git a/apps/web/lib/types/task-launch-error.ts b/apps/web/lib/types/task-launch-error.ts new file mode 100644 index 0000000000..8fba233ba9 --- /dev/null +++ b/apps/web/lib/types/task-launch-error.ts @@ -0,0 +1,28 @@ +export const TASK_LAUNCH_RECOVERY_ACTIONS = [ + "retry_default", + "pick_base_branch", + "mark_review_done", +] as const; + +export type TaskLaunchRecoveryAction = (typeof TASK_LAUNCH_RECOVERY_ACTIONS)[number]; + +/** Keep recovery controls bounded and in the order declared by the backend. */ +export function normalizeTaskLaunchRecoveryActions(value: unknown): TaskLaunchRecoveryAction[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const actions: TaskLaunchRecoveryAction[] = []; + for (const candidate of value) { + if ( + typeof candidate !== "string" || + !TASK_LAUNCH_RECOVERY_ACTIONS.includes(candidate as TaskLaunchRecoveryAction) + ) { + continue; + } + const action = candidate as TaskLaunchRecoveryAction; + if (seen.has(action)) continue; + seen.add(action); + actions.push(action); + if (actions.length === 3) break; + } + return actions; +} diff --git a/apps/web/lib/types/task-status-summary.ts b/apps/web/lib/types/task-status-summary.ts index 4f26812902..aead4b740a 100644 --- a/apps/web/lib/types/task-status-summary.ts +++ b/apps/web/lib/types/task-status-summary.ts @@ -1,4 +1,15 @@ import type { ForegroundActivity, TaskPendingAction, TaskSessionState } from "./http"; +import type { TaskLaunchRecoveryAction } from "./task-launch-error"; + +export type TaskStatusSummaryActiveError = { + session_id?: string; + task_repository_id?: string; + stamp: string; + occurred_at: string; + preview: string; + category?: string; + recovery_actions?: TaskLaunchRecoveryAction[]; +}; export type TaskStatusSummary = { revision: number; @@ -14,12 +25,7 @@ export type TaskStatusSummary = { pending_action?: TaskPendingAction; /** Number of prompts currently en-queued for the task (all sessions). */ queued_prompt_count?: number; - active_error?: { - session_id: string; - stamp: string; - occurred_at: string; - preview: string; - } | null; + active_error?: TaskStatusSummaryActiveError | null; git?: { additions?: number; deletions?: number; diff --git a/apps/web/lib/ws/handlers/agent-session.test.ts b/apps/web/lib/ws/handlers/agent-session.test.ts index fe19978a99..d7772f5721 100644 --- a/apps/web/lib/ws/handlers/agent-session.test.ts +++ b/apps/web/lib/ws/handlers/agent-session.test.ts @@ -227,6 +227,35 @@ describe("session.state_changed handler", () => { }); }); + it("classifies stamped typed failures as launch failures without actions", () => { + store = makeStore({ + taskSessions: { + items: { "s-1": { id: "s-1", task_id: "t-1", state: "STARTING" } }, + }, + }); + handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; + + handler( + makeMessage({ + task_id: "t-1", + session_id: "s-1", + new_state: "FAILED", + error_message: "pull request is closed", + session_metadata: { + last_agent_error: { + message: "pull request is closed", + code: "pr_already_closed", + stamp: "closed-pr-stamp", + }, + }, + }), + ); + + expect(store.getState().setSessionFailureNotification).toHaveBeenCalledWith( + expect.objectContaining({ isLaunchFailure: true }), + ); + }); + it("does not set failure notification when session is already FAILED", () => { store = makeStore({ taskSessions: { diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index b4a29f3dd7..8f6fd8bf2d 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -16,6 +16,7 @@ import { syncKanbanPrimarySessionState } from "@/lib/ws/handlers/agent-session-k import { parseContextWindowEntry } from "@/lib/state/slices/session-runtime/context-window"; import { t } from "@/lib/i18n"; import { maybeMarkQuickChatUnseenIdle } from "@/lib/ws/handlers/quick-chat-unseen"; +import { readLastAgentError } from "@/lib/session-last-agent-error"; const debug = createDebugLogger("session:state"); @@ -553,12 +554,24 @@ function maybeNotifySessionFailure(store: StoreApi, ctx: SessionFailur return; } + const metadata = + payload.session_metadata && typeof payload.session_metadata === "object" + ? (payload.session_metadata as Record) + : null; + const launchError = readLastAgentError(metadata); + const isLaunchFailure = Boolean(launchError?.stamp && launchError.code); + let message = t("task:sessionFailedUnexpectedly"); + if (isLaunchFailure) { + message = t("task:launchFailedSeeDetails"); + } else if (payload.error_message) { + message = String(payload.error_message); + } + store.getState().setSessionFailureNotification({ sessionId, taskId, - message: payload.error_message - ? String(payload.error_message) - : t("task:sessionFailedUnexpectedly"), + message, + ...(isLaunchFailure ? { isLaunchFailure: true } : {}), }); } diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 18f5f54c91..2512fa199a 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -1790,5 +1790,20 @@ "updateFailed": "Update failed", "workspaceRoot": "Workspace root", "worktreeExecutorRequiresRepository": "Worktree executor requires a repository.", - "worktreeNumbered": "Worktree {{index}}" + "worktreeNumbered": "Worktree {{index}}", + "launchErrorBaseBranchMissing": "The task could not start because its base branch is unavailable.", + "launchErrorDefaultBranchUnresolved": "The task could not resolve the repository default branch.", + "launchErrorPrAlreadyClosed": "The task is linked to a closed pull request.", + "launchErrorGeneric": "The task could not start.", + "launchErrorSessionPreview": "The task launch needs attention.", + "launchNeedsAttention": "Launch needs attention", + "retryWithDefaultBranch": "Retry with default branch", + "pickBaseBranch": "Pick a base branch", + "markReviewDone": "Mark review done", + "chooseBaseBranch": "Choose a base branch", + "chooseBaseBranchDescription": "Select the branch to use before the task relaunches.", + "baseBranchNotSet": "Base branch not set", + "launchRecoveryFailed": "Recovery could not start", + "tryRecoveryAgain": "Try the recovery action again.", + "launchFailedSeeDetails": "The task launch failed. Open the task details for recovery actions." } diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index b1a97e73e1..e8524227e5 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -1790,5 +1790,20 @@ "updateFailed": "Ũƥďàţē ƒàĩĺēď", "workspaceRoot": "Ŵōŕķśƥàćē ŕōōţ", "worktreeExecutorRequiresRepository": "Ŵōŕķţŕēē ēxēćũţōŕ ŕēqũĩŕēś à ŕēƥōśĩţōŕŷ.", - "worktreeNumbered": "Ŵōŕķţŕēē {{index}}" + "worktreeNumbered": "Ŵōŕķţŕēē {{index}}", + "launchErrorBaseBranchMissing": "Ţĥē ţàśķ ćōũĺď ńōţ śţàŕţ ƀēćàũśē ĩţś ƀàśē ƀŕàńćĥ ĩś ũńàvàĩĺàƀĺē.", + "launchErrorDefaultBranchUnresolved": "Ţĥē ţàśķ ćōũĺď ńōţ ŕēśōĺvē ţĥē ŕēƥōśĩţōŕŷ ďēƒàũĺţ ƀŕàńćĥ.", + "launchErrorPrAlreadyClosed": "Ţĥē ţàśķ ĩś ĺĩńķēď ţō à ćĺōśēď ƥũĺĺ ŕēqũēśţ.", + "launchErrorGeneric": "Ţĥē ţàśķ ćōũĺď ńōţ śţàŕţ.", + "launchErrorSessionPreview": "Ţĥē ţàśķ ĺàũńćĥ ńēēďś àţţēńţĩōń.", + "launchNeedsAttention": "Ĺàũńćĥ ńēēďś àţţēńţĩōń", + "retryWithDefaultBranch": "Ŕēţŕŷ ŵĩţĥ ďēƒàũĺţ ƀŕàńćĥ", + "pickBaseBranch": "Ƥĩćķ à ƀàśē ƀŕàńćĥ", + "markReviewDone": "Ḿàŕķ ŕēvĩēŵ ďōńē", + "chooseBaseBranch": "Ćĥōōśē à ƀàśē ƀŕàńćĥ", + "chooseBaseBranchDescription": "Śēĺēćţ ţĥē ƀŕàńćĥ ţō ũśē ƀēƒōŕē ţĥē ţàśķ ŕēĺàũńćĥēś.", + "baseBranchNotSet": "Ɓàśē ƀŕàńćĥ ńōţ śēţ", + "launchRecoveryFailed": "Ŕēćōvēŕŷ ćōũĺď ńōţ śţàŕţ", + "tryRecoveryAgain": "Ţŕŷ ţĥē ŕēćōvēŕŷ àćţĩōń àĝàĩń.", + "launchFailedSeeDetails": "Ţĥē ţàśķ ĺàũńćĥ ƒàĩĺēď. Ōƥēń ţĥē ţàśķ ďēţàĩĺś ƒōŕ ŕēćōvēŕŷ àćţĩōńś." } diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index 81e032b260..2156775c8a 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -1790,5 +1790,20 @@ "expandPrompt": "Expandir prompt", "promptHistory": "Histórico de prompts", "promptHistoryEmpty": "Ainda sem prompts.", - "scrollToPrompt": "Ir para o prompt" + "scrollToPrompt": "Ir para o prompt", + "launchErrorBaseBranchMissing": "Não foi possível iniciar a tarefa porque o branch base não está disponível.", + "launchErrorDefaultBranchUnresolved": "Não foi possível resolver o branch predefinido do repositório.", + "launchErrorPrAlreadyClosed": "A tarefa está associada a um pull request fechado.", + "launchErrorGeneric": "Não foi possível iniciar a tarefa.", + "launchErrorSessionPreview": "O início da tarefa requer atenção.", + "launchNeedsAttention": "O início requer atenção", + "retryWithDefaultBranch": "Tentar novamente com o branch predefinido", + "pickBaseBranch": "Escolher um branch base", + "markReviewDone": "Marcar revisão como concluída", + "chooseBaseBranch": "Escolher um branch base", + "chooseBaseBranchDescription": "Selecione o branch a usar antes de reiniciar a tarefa.", + "baseBranchNotSet": "Branch base não definido", + "launchRecoveryFailed": "Não foi possível iniciar a recuperação", + "tryRecoveryAgain": "Tente novamente a ação de recuperação.", + "launchFailedSeeDetails": "O início da tarefa falhou. Abra os detalhes da tarefa para ver as ações de recuperação." } diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index ef3ba0e7cf..16b3179c0c 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -1790,5 +1790,20 @@ "expandPrompt": "展开提示", "promptHistory": "提示历史", "promptHistoryEmpty": "暂无提示。", - "scrollToPrompt": "滚动到提示" + "scrollToPrompt": "滚动到提示", + "launchErrorBaseBranchMissing": "任务无法启动,因为基础分支不可用。", + "launchErrorDefaultBranchUnresolved": "无法解析仓库的默认分支。", + "launchErrorPrAlreadyClosed": "任务关联的拉取请求已关闭。", + "launchErrorGeneric": "任务无法启动。", + "launchErrorSessionPreview": "任务启动需要处理。", + "launchNeedsAttention": "启动需要处理", + "retryWithDefaultBranch": "使用默认分支重试", + "pickBaseBranch": "选择基础分支", + "markReviewDone": "标记评审完成", + "chooseBaseBranch": "选择基础分支", + "chooseBaseBranchDescription": "选择任务重新启动前要使用的分支。", + "baseBranchNotSet": "未设置基础分支", + "launchRecoveryFailed": "无法开始恢复", + "tryRecoveryAgain": "请再次尝试恢复操作。", + "launchFailedSeeDetails": "任务启动失败。打开任务详情以查看恢复操作。" } diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 22ff336f54..ae15fde729 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -1790,5 +1790,20 @@ "expandPrompt": "展開提示", "promptHistory": "提示歷史", "promptHistoryEmpty": "暫無提示。", - "scrollToPrompt": "滾動到提示" + "scrollToPrompt": "滾動到提示", + "launchErrorBaseBranchMissing": "任務無法啓動,因為基礎分支不可用。", + "launchErrorDefaultBranchUnresolved": "無法解析儲存庫的預設分支。", + "launchErrorPrAlreadyClosed": "任務關聯的拉取要求已關閉。", + "launchErrorGeneric": "任務無法啓動。", + "launchErrorSessionPreview": "任務啓動需要處理。", + "launchNeedsAttention": "啓動需要處理", + "retryWithDefaultBranch": "使用預設分支重試", + "pickBaseBranch": "選擇基礎分支", + "markReviewDone": "標記審查完成", + "chooseBaseBranch": "選擇基礎分支", + "chooseBaseBranchDescription": "選擇任務重新啓動前要使用的分支。", + "baseBranchNotSet": "未設定基礎分支", + "launchRecoveryFailed": "無法開始恢復", + "tryRecoveryAgain": "請再次嘗試恢復操作。", + "launchFailedSeeDetails": "任務啓動失敗。開啟任務詳情以查看恢復操作。" } diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index f551327465..5aa8ad9df1 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -1790,5 +1790,20 @@ "expandPrompt": "展開提示", "promptHistory": "提示歷史", "promptHistoryEmpty": "暫無提示。", - "scrollToPrompt": "滾動到提示" + "scrollToPrompt": "滾動到提示", + "launchErrorBaseBranchMissing": "任務無法啟動,因為基礎分支不可用。", + "launchErrorDefaultBranchUnresolved": "無法解析儲存庫的預設分支。", + "launchErrorPrAlreadyClosed": "任務關聯的提取要求已關閉。", + "launchErrorGeneric": "任務無法啟動。", + "launchErrorSessionPreview": "任務啟動需要處理。", + "launchNeedsAttention": "啟動需要處理", + "retryWithDefaultBranch": "使用預設分支重試", + "pickBaseBranch": "選擇基礎分支", + "markReviewDone": "標記審查完成", + "chooseBaseBranch": "選擇基礎分支", + "chooseBaseBranchDescription": "選擇任務重新啟動前要使用的分支。", + "baseBranchNotSet": "未設定基礎分支", + "launchRecoveryFailed": "無法開始恢復", + "tryRecoveryAgain": "請再次嘗試恢復操作。", + "launchFailedSeeDetails": "任務啟動失敗。開啟任務詳情以檢視恢復操作。" } diff --git a/docs/plans/task-launch-failure-recovery/plan.md b/docs/plans/task-launch-failure-recovery/plan.md new file mode 100644 index 0000000000..bcd1e49777 --- /dev/null +++ b/docs/plans/task-launch-failure-recovery/plan.md @@ -0,0 +1,334 @@ +--- +spec: docs/specs/task-launch-failure-recovery/spec.md +created: 2026-08-19 +status: done +--- + +# Implementation Plan: Task Launch Failure Recovery + +## Overview + +The implementation first defines durable error and repository-target contracts. +It then hardens Git resolution, carries exact row identity, and adds launch policy. +The final waves add task-scoped recovery, bounded delivery, responsive UI, translations, and E2E coverage. + +## Confirmed root cause + +- `gitref.DefaultBranch` falls back to the current local `HEAD` branch when no origin ref resolves. + Repository import can therefore persist a feature branch as the default. +- `resolveBaseRefWithFallback` stops when the selected base and stored default are absent. + It does not refresh `origin/HEAD` before it returns `ErrInvalidBaseBranch`. +- `autoStartTaskForLoadedStep` calls `StartTask` before it checks the relevant PR state. + No session exists at this point, so session metadata cannot own the gate result. +- The lifecycle request carries `RepositoryID`, but it drops `TaskRepository.ID`. + The recovery and self-heal paths cannot target one same-repository branch row safely. +- `handleSessionLaunchFailed` creates separate warning messages, archive/delete actions, and toast suppression. + That path conflicts with a typed task error and its recovery actions. + +## Backend + +### Error contracts and persistence owners + +- `apps/backend/internal/task/models/models.go` + - Add typed launch categories and recovery-action constants. + - Extend `LastAgentError` with `RecoveryActions`, `TaskRepositoryID`, and an explicit bounded stamp. + - Add `TaskLaunchError` and `MetaKeyLastLaunchError` for pre-session outcomes. + - Add load, store, clear, and stable-stamp helpers for the task metadata record. +- Keep session-created launch errors in `task_sessions.metadata["last_agent_error"]`. +- Store the PR auto-start gate in `tasks.metadata["last_launch_error"]`. +- Add atomic, key-scoped task-error set and compare-and-clear operations. + They do not rewrite the full metadata object or clear a newer stamp. +- Make repeated writes of the same gate result a semantic no-op. + +### Local default detection + +- `apps/backend/internal/common/gitref/gitref.go` + - Return the resolution source with the branch name inside the package. + - Make `DefaultBranchOrEmpty` return empty for a current-`HEAD`-only result. + - Keep `DefaultBranch` behavior unchanged for callers that need a display fallback. +- Keep `internal/common/gitref` free of subprocess and network work. + +### Live remote-default refresh + +- `apps/backend/internal/worktree/manager_lifecycle.go` + - Add exported `Manager.ResolveRemoteDefaultBranch(ctx, repoPath)`. + - Read `refs/remotes/origin/HEAD` before the helper runs a command. + - Run `git remote set-head origin --auto` through the existing Git admission and classification path. + - Use `Manager.inspectTimeout` and the caller context. + - Set the existing noninteractive Git environment, including `GIT_TERMINAL_PROMPT=0`. + - Preserve auth, network, cancellation, and timeout errors as typed causes. +- `resolveBaseRefWithFallback` uses the live default after the stored default fails. + It returns `ErrInvalidBaseBranch` only when every branch source is absent. + +### Exact task-repository identity + +Carry `TaskRepositoryID` through every per-repository launch boundary: + +```text +executor.repoInfo + -> executor.RepoSpec + -> lifecycle.RepoLaunchSpec + -> lifecycle.LaunchRequest.RepoSpecs() + -> lifecycle.RepoPrepareSpec + -> lifecycle.EnvPrepareRequest.RepoSpecs() + -> lifecycle.RepoWorktreeResult +``` + +Also add the legacy top-level single-repository field where the request synthesizes one spec. +Update fresh launch, resume, environment reuse, and multi-repository tests. + +The launch-failure result and `LastAgentError` record carry the exact row ID. +If identity is missing or ambiguous, omit branch recovery actions. + +### Worktree fallback self-heal + +- `apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go` + - Preserve the requested base before worktree creation. + - Return the resolved `Worktree.BaseBranch` and fallback warning with `TaskRepositoryID`. +- At the orchestrator boundary, inspect each prepared worktree result. + When fallback occurred, call `Service.UpdateRepositoryBaseBranch` for the exact row. +- Keep the worktree manager and lifecycle packages free of a task-service dependency. +- Treat the update as best effort. + A write error logs a warning and does not fail the launch. + +### PR auto-start gate + +- Add a narrow PR reader to the orchestrator. +- Load the task-repository rows and active PR links before `StartTask`. +- Match a row by exact `(repository_id, pr_number)` when metadata has a positive PR number. +- Otherwise match exact normalized repository and checkout/head branch values. +- Normalize branches by trimming whitespace and one documented Git ref prefix. + Keep the final comparison case-sensitive. +- Skip launch only when at least one relevant PR exists and all relevant states are terminal. +- Store `TaskLaunchError{code: pr_already_closed}` with a stable stamp from relevant PR identity and state. +- Add `mark_review_done` only when the workflow has a valid terminal final step. +- Fail open for lookup errors, no match, open state, empty state, or unknown state. +- Clear the task-owned gate result after a later launch succeeds. + +### Session launch classification + +- `apps/backend/internal/orchestrator/executor/executor_execute.go` + - Classify `ErrInvalidBaseBranch` as `base_branch_missing`. + - Attach the exact `TaskRepositoryID` and only valid recovery actions. + - Persist the extended `LastAgentError` with the existing failed state changes. +- Inject a narrow review-completion eligibility capability into the executor. + It requires a valid terminal final step and terminal relevant PR state. + +### Legacy launch-guidance removal + +- Remove the handled missing-branch message path from `handleSessionLaunchFailed`. +- Remove its archive/delete action metadata and `missing_pr_branch_recovery_claimed` use. +- Do not set `suppressToast` for a handled typed launch error. +- Retain unrelated recovery and toast-suppression behavior. + +### Task launch recovery action + +- Register `task.launch.recover` in the orchestrator handler set and shared WS action constants. +- Authorize the task before any lookup. +- Require `error_stamp` and compare it with the current projected source record. +- Validate the optional session-task pair and repository-row ownership. +- For `retry_default`, use the worktree manager remote-default resolver. + Update the repository default and exact task base before relaunch. +- For `pick_base_branch`, validate the selected remote branch. + Update the exact task base before relaunch. +- For `mark_review_done`, list workflow steps and select the final valid terminal step. + Call `task.Service.MoveTaskWithOptions` so WIP, state, history, and events stay consistent. +- Re-evaluate relevant PR state before the move. + Reject the action when a relevant PR is open, unknown, or no longer matches. +- Add a recovery-only move option that permits `FAILED` to become `COMPLETED` at that step. + Do not change failed-state preservation for normal moves. +- Clear the source error only after the recovery action succeeds. +- Use a compare-and-clear write so success cannot erase a newer error. +- Replace it with `default_branch_unresolved` when a default cannot resolve. + +### Bounded status projection + +- Update `apps/backend/internal/task/statussummary/model.go` with optional `SessionID`, optional + `TaskRepositoryID`, `Category`, and `RecoveryActions`. +- Bound category and row IDs to 64 and 256 UTF-8 bytes. +- Accept only the three known action values. + Deduplicate them, preserve order, and keep at most three. +- Update live projection and authoritative rebuild to read both error sources. +- Select the newest active record by `occurred_at`, then stable stamp. +- Ignore malformed task metadata instead of rejecting the complete summary. +- Update `docs/specs/platform/bounded-task-status-delivery.md` with this contract. + +## Frontend + +### Types and live summary selection + +- Extend `apps/web/lib/types/task-status-summary.ts` with the new bounded fields. +- Extend `OfficeTask` and local task-detail `Task` with `statusSummary`. +- Map `status_summary` in `apps/web/app/office/tasks/[id]/page.tsx`. +- Add a selector that returns the newest summary for a task across detail data and live kanban caches. +- Update `statusSummaryActiveErrorPreview` so task-owned errors do not require a session ID. +- Extend `LastAgentError`, `RunError`, and their parsers with recovery fields and row identity. + +### Shared launch-error card + +- Add `apps/web/components/task/simple/components/task-launch-error-entry.tsx`. +- Render the card from `TaskStatusSummary.active_error` before the Chat empty state. +- Reuse the card from `RunErrorEntry` for typed session launch errors. + Keep existing resume, fresh-start, and managed-runtime behavior unchanged. +- Suppress the summary card when a matching session run-error card already renders. +- Send the three new actions through `task.launch.recover`. +- Keep action logic and error state shared across desktop and mobile. +- Replace the raw launch toast with `task:launchFailedSeeDetails`. + +### Branch picker and mobile contract + +- Extract reusable branch-list and repository-resolution logic from + `apps/web/components/task/base-branch-picker.tsx`. +- Desktop opens the existing popover pattern from the Pick a base branch action. +- Phone opens `apps/web/components/task/mobile/mobile-picker-sheet.tsx` from the same action. +- The task Chat surface is the desktop and phone entry point. +- The error card stays inline because it is short and task-specific. +- Actions wrap and use at least 44px touch targets. +- The task Chat surface owns page scrolling. + The picker owns list scrolling and safe-area padding. +- No document-level horizontal overflow is permitted. + +### Internationalization + +- Add category headlines, action labels, picker text, and pointer-toast text to all five task catalogs. +- Write English, `pt-pt`, and `zh-cn` values directly. +- Generate `zh-hk` and `zh-tw` with `pnpm run i18n:zh-hant`. + +## Tests + +- **Local default detection** + - File: `apps/backend/internal/common/gitref/gitref_test.go` + - Prove that current-`HEAD`-only persistence returns empty. + - Prove that origin and local main/master refs keep current behavior. +- **Live remote default** + - File: `apps/backend/internal/worktree/manager_lifecycle_test.go` + - Prove success from existing `origin/HEAD` and refreshed `origin/HEAD`. + - Prove cancellation, bounded timeout, auth/network classification, and unresolved behavior. +- **Repository identity and self-heal** + - Files: executor multi-repository tests and lifecycle worktree-preparer tests. + - Prove exact identity for same-repository multi-branch rows, fallback writes, no-op, and write-error continuation. +- **PR gate** + - File: `apps/backend/internal/orchestrator/event_handlers_workflow_test.go` + - Cover merged, closed, open, mixed states, unknown state, no row, lookup error, and replay idempotency. + - Cover exact PR-number matching and same-repository sibling branches. +- **Launch classification** + - File: `apps/backend/internal/orchestrator/executor/executor_execute_test.go` + - Start with a regression that lacks a typed error and exact row target. + - Prove missing-base and generic categories plus ambiguous-target action removal. +- **Legacy consolidation** + - File: `apps/backend/internal/orchestrator/task_launch_failure_test.go` + - Prove that handled errors create no warning message, archive/delete actions, claim, or toast suppression. +- **Recovery action** + - File: the owning orchestrator WS handler test. + - Cover task/session/repository ownership, all three actions, forged payloads, and retry idempotency. + - Cover a workflow with no valid terminal final step and a PR that reopens before the action. +- **Status projection** + - Files: `apps/backend/internal/task/statussummary/*_test.go` and rebuild service tests. + - Cover newest-source selection, restart rebuild, bounds, known-action filtering, and malformed metadata. +- **Frontend** + - Files: focused tests beside the parser, selector, Chat component, launch-error card, and `RunErrorEntry`. + - Prove the no-session card, exact WS payloads, existing recovery preservation, and branch picker composition. + +## E2E Tests + +- **Desktop merged-PR gate** + - File: `apps/web/e2e/tests/task/launch-failure-recovery.spec.ts` + - Prove no session starts, the durable card survives reload, and Mark review done moves the task. +- **Desktop missing-base recovery** + - Same file. + - Prove exact-row retry and branch selection, relaunch, self-heal, reload, and pointer toast. +- **Legacy missing-branch regression** + - File: `apps/web/e2e/tests/pr/pr-watcher-missing-branch.spec.ts` + - Replace warning-message and archive/delete expectations with the typed task-error outcome. +- **Mobile recovery parity** + - File: `apps/web/e2e/tests/task/mobile-launch-failure-recovery.spec.ts` + - Prove the same branch-selection outcome through `MobilePickerSheet`. + - Prove 44px action targets, viewport containment, internal picker scrolling, and no page overflow. + +Use the managed runner so each E2E command builds production assets and performs cleanup. + +## Verification Results + +Implemented and verified on 2026-08-20. + +- `cd apps/backend && go test ./internal/orchestrator/... ./internal/agent/runtime/lifecycle/... ./internal/worktree/...`: 5,139 tests passed in 13 packages. +- `cd apps/backend && make build`: passed. +- `cd apps/backend && make e2e-plugin-package`: passed. +- `cd apps/web && pnpm run lint`: passed. +- `cd apps/web && pnpm run typecheck`: passed. +- `cd apps/web && pnpm run i18n:check`: passed. The catalogs contain 7,159 referenced keys and complete translations. +- Focused frontend recovery and status tests: 4 files and 50 tests passed. +- Responsive recovery tests: 2 files and 24 tests passed. +- Desktop task recovery E2E: 2 tests passed. +- Mobile task recovery E2E: 1 test passed. +- Desktop and mobile PR watcher E2E: 1 test passed in each project. +- Desktop and mobile recovery screenshots were captured and inspected. + +No public documentation update was required. The change affects internal task recovery behavior. + +## PR Fixup Remediation + +PR #2832 review and CI findings were addressed before delivery. + +- Recovery now reloads all task and session error sources and rejects an action + when its source is not the newest active error. +- Workflow terminal-step traversal uses one shared helper. +- Session metadata compare-and-clear propagates `RowsAffected` errors. +- Windows treats the wildcard-bind `WSAEACCES` conflict as a port collision. +- Typed launch errors remain visible when they have no recovery action. +- Summary polling keeps the highest revision and duplicate recovery surfaces + show the same pending action. +- `cd apps/backend && make lint`: passed with zero issues. +- `cd apps/backend && go test ./internal/orchestrator/... ./internal/common/netutil/... ./internal/task/repository/sqlite/...`: 3,468 tests passed in 11 packages. +- `cd apps/backend && go test -c -o /tmp/kandev-netutil-windows.test.exe ./internal/common/netutil`: passed. +- `cd apps/web && pnpm run lint`: passed. +- `cd apps/web && pnpm run typecheck`: passed. +- Focused frontend fixup tests: 3 files and 64 tests passed. + +### Second PR fixup + +- Removed the no-op launch-failure callback and retargeted its test to the real + state-transition fallback. +- Added bounded message persistence, exact explicit-stamp matching, sanitized + unresolved-default details, and an environment-gated Postgres CAS test. +- Replaced stale full-row default-branch writes with an expected-value guarded + column update. +- Rejected empty relaunch responses, preserved successful relaunches when + compare-and-clear logging is needed, and added session-owned recovery tests. +- Required matching session error stamps in the chat timeline and rendered + only typed task errors in the recovery layout branch. +- Added focused positive typed-error assertions and mutex-safe background + launch checks. + +## Implementation Waves And Parallel Candidates + +```text +Wave 1 (parallel candidates, user authorization required): +- [x] [task-01-failure-taxonomy-contracts](task-01-failure-taxonomy-contracts.md) +- [x] [task-02-gitref-default-hardening](task-02-gitref-default-hardening.md) + +Wave 2: +- [x] [task-03-worktree-live-default-fallback](task-03-worktree-live-default-fallback.md) (depends 02) +- [x] [task-05-pr-review-autostart-gating](task-05-pr-review-autostart-gating.md) (depends 01) +- [x] [task-10-task-base-self-heal](task-10-task-base-self-heal.md) (depends 01) + +Wave 3: +- [x] [task-04-launch-failure-classification](task-04-launch-failure-classification.md) (depends 01,10) + +Wave 4: +- [x] [task-07-status-summary-projection](task-07-status-summary-projection.md) (depends 01,04,05) +- [x] [task-12-remove-legacy-launch-guidance](task-12-remove-legacy-launch-guidance.md) (depends 04) + +Wave 5: +- [x] [task-06-recovery-actions-ws](task-06-recovery-actions-ws.md) (depends 01,02,03,04,05,10,12) +- [x] [task-08-frontend-failure-surface-and-recovery](task-08-frontend-failure-surface-and-recovery.md) (depends 07) + +Wave 6: +- [x] [task-11-responsive-launch-error-surface](task-11-responsive-launch-error-surface.md) (depends 06,08) + +Wave 7: +- [x] [task-09-e2e-and-i18n](task-09-e2e-and-i18n.md) (depends 11) +``` + +The default execution order is sequential in the primary conversation. +The waves do not authorize subagents. diff --git a/docs/plans/task-launch-failure-recovery/task-01-failure-taxonomy-contracts.md b/docs/plans/task-launch-failure-recovery/task-01-failure-taxonomy-contracts.md new file mode 100644 index 0000000000..d974b63eb8 --- /dev/null +++ b/docs/plans/task-launch-failure-recovery/task-01-failure-taxonomy-contracts.md @@ -0,0 +1,40 @@ +--- +id: "01-failure-taxonomy-contracts" +title: "Launch-error persistence contracts" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/task-launch-failure-recovery/spec.md" +--- + +# Task 01: Launch-error persistence contracts + +Define the shared categories, actions, and persistence owners. +Do not change launch behavior in this task. + +- **Acceptance:** + 1. `LastAgentError` gains recovery actions, task-repository identity, and an explicit bounded stamp. + 2. `TaskLaunchError` persists under `tasks.metadata["last_launch_error"]` with the spec fields. + 3. Typed category and recovery-action constants exist in one importable package. + 4. Load, normalize, and stable-stamp helpers have focused tests. + 5. Typed session errors use the explicit stamp. Legacy errors retain their computed-stamp fallback. + 6. Rewriting the same task-owned stamp does not change `occurred_at` or task metadata. + 7. `ErrInvalidBaseBranch` remains detectable through `errors.Is`. + +- **Verification:** + `cd apps/backend && go test ./internal/task/models/... ./internal/worktree/...` + +- **Files likely touched:** + `apps/backend/internal/task/models/models.go`, + `apps/backend/internal/task/models/models_test.go`, + `apps/backend/internal/worktree/errors.go`. + +- **Dependencies:** None. +- **Parallelism:** parallel-safe (disjoint from task-02). +- **Inputs:** spec "Data model" and "Persistence guarantees". + +## Results +- Added bounded task/session launch-error contracts, typed categories/actions, explicit stamps, deterministic stamp hashing, and compare-by-stamp in-memory helpers. +- Added focused persistence, normalization, no-op rewrite, clear, and sentinel tests. +- Verification: `go test ./internal/task/models/... ./internal/worktree/...` passed. diff --git a/docs/plans/task-launch-failure-recovery/task-02-gitref-default-hardening.md b/docs/plans/task-launch-failure-recovery/task-02-gitref-default-hardening.md new file mode 100644 index 0000000000..5271545dbc --- /dev/null +++ b/docs/plans/task-launch-failure-recovery/task-02-gitref-default-hardening.md @@ -0,0 +1,37 @@ +--- +id: "02-gitref-default-hardening" +title: "Local default-branch detection" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/task-launch-failure-recovery/spec.md" +--- + +# Task 02: Local default-branch detection + +Stop persistence callers from recording a current-`HEAD`-only feature branch. +Keep `gitref` local and free of subprocess work. + +- **Acceptance:** + 1. An internal result identifies whether the branch came only from `readHEADBranchFallback`. + 2. `DefaultBranchOrEmpty` returns `""` for that source. + 3. `DefaultBranch` keeps its existing display-fallback behavior. + 4. Origin refs and local `main` or `master` keep their current detection order. + 5. The package adds no `os/exec`, `subproc`, network, or context dependency. + +- **Verification:** + `cd apps/backend && go test ./internal/common/gitref/...` + +- **Files likely touched:** + `apps/backend/internal/common/gitref/gitref.go`, + `apps/backend/internal/common/gitref/gitref_test.go`. + +- **Dependencies:** None. +- **Parallelism:** parallel-safe (disjoint from task-01). +- **Inputs:** spec "Default-branch resolution" and plan "Local default detection". + +## Results +- `DefaultBranch` retains its existing integration-branch precedence. +- `DefaultBranchOrEmpty` now returns empty only when the current HEAD is the final fallback, including feature-only repositories. +- Verification: `go test ./internal/common/gitref/...` passed. diff --git a/docs/plans/task-launch-failure-recovery/task-03-worktree-live-default-fallback.md b/docs/plans/task-launch-failure-recovery/task-03-worktree-live-default-fallback.md new file mode 100644 index 0000000000..b6ec34d192 --- /dev/null +++ b/docs/plans/task-launch-failure-recovery/task-03-worktree-live-default-fallback.md @@ -0,0 +1,38 @@ +--- +id: "03-worktree-live-default-fallback" +title: "Worktree fallback resolves live remote default" +status: done +wave: 2 +depends_on: ["02-gitref-default-hardening"] +plan: "plan.md" +spec: "../../specs/task-launch-failure-recovery/spec.md" +--- + +# Task 03: Worktree fallback resolves live remote default + +When the stored fallback is absent, refresh the live remote default through the worktree manager. + +- **Acceptance:** + 1. Exported `Manager.ResolveRemoteDefaultBranch(ctx, repoPath)` first reads `refs/remotes/origin/HEAD`. + 2. If necessary, it runs `git remote set-head origin --auto` through existing Git admission. + 3. The command uses caller cancellation, `Manager.inspectTimeout`, and noninteractive Git environment. + 4. Auth, network, cancellation, timeout, and unresolved results remain distinguishable. + 5. `resolveBaseRefWithFallback` uses the live result after the stored fallback fails. + 6. The resolved branch and fallback warning still reach the created `Worktree`. + 7. The manager stays task-agnostic and imports no task service. + +- **Verification:** + `cd apps/backend && go test ./internal/worktree/...` + +- **Files likely touched:** + `apps/backend/internal/worktree/manager_lifecycle.go`, + `apps/backend/internal/worktree/manager_lifecycle_test.go`. + +- **Dependencies:** Task 02. +- **Parallelism:** sequential. +- **Inputs:** spec "Default-branch resolution" and "Failure modes". + +## Results +- Added `Manager.ResolveRemoteDefaultBranch` with local `origin/HEAD` inspection, bounded noninteractive refresh, typed auth/network/timeout/unresolved outcomes, and caller cancellation. +- Missing stored fallbacks now try the live remote default, and the existing worktree warning/detail path receives the resolved branch. +- Verification: `go test ./internal/worktree/...` passed. diff --git a/docs/plans/task-launch-failure-recovery/task-04-launch-failure-classification.md b/docs/plans/task-launch-failure-recovery/task-04-launch-failure-classification.md new file mode 100644 index 0000000000..0d2bd92909 --- /dev/null +++ b/docs/plans/task-launch-failure-recovery/task-04-launch-failure-classification.md @@ -0,0 +1,45 @@ +--- +id: "04-launch-failure-classification" +title: "Persist typed launch-failure reason" +status: done +wave: 3 +depends_on: ["01-failure-taxonomy-contracts", "10-task-base-self-heal"] +plan: "plan.md" +spec: "../../specs/task-launch-failure-recovery/spec.md" +--- + +# Task 04: Persist typed launch-failure reason + +Persist the typed session error with an exact row target. + +- **Acceptance:** + 1. `transitionLaunchFailure` maps `ErrInvalidBaseBranch` to `base_branch_missing`. + 2. The record carries `TaskRepositoryID` from the lifecycle failure result. + 3. Repository actions are absent when that identity is empty or ambiguous. + 4. A narrow eligibility resolver controls whether the record includes `mark_review_done`. + 5. Eligibility requires a valid terminal step and terminal relevant PR state. + 6. Resolver absence or error omits that action and does not block error persistence. + 7. The existing session and task failed-state transitions stay unchanged. + +- **Verification:** + `cd apps/backend && go test ./internal/orchestrator/executor/... -race` + +- **Files likely touched:** + `apps/backend/internal/orchestrator/executor/executor_execute.go`, + `apps/backend/internal/orchestrator/executor/executor_execute_test.go`, + the narrow review-completion eligibility resolver and composition wiring. + +- **Dependencies:** Task 01 and Task 10. +- **Parallelism:** sequential. +- **Inputs:** spec "Session-owned launch error" and "Failure modes". + +## Results +- Added typed launch-failure classification for missing base branches, unresolved + defaults, and generic startup failures. +- Persisted the bounded `LastAgentError` with the exact task-repository ID when + lifecycle identity is unambiguous, and omitted repository actions otherwise. +- Added a narrow review-completion eligibility resolver. Resolver errors and + missing wiring fail open without blocking error persistence. +- Preserved the existing failed-session transition and callback behavior. +- Verification: `cd apps/backend && go test ./internal/orchestrator/executor/... -race` + passed with 445 tests. diff --git a/docs/plans/task-launch-failure-recovery/task-05-pr-review-autostart-gating.md b/docs/plans/task-launch-failure-recovery/task-05-pr-review-autostart-gating.md new file mode 100644 index 0000000000..2f687000cb --- /dev/null +++ b/docs/plans/task-launch-failure-recovery/task-05-pr-review-autostart-gating.md @@ -0,0 +1,51 @@ +--- +id: "05-pr-review-autostart-gating" +title: "Gate terminal PR auto-start" +status: done +wave: 2 +depends_on: ["01-failure-taxonomy-contracts"] +plan: "plan.md" +spec: "../../specs/task-launch-failure-recovery/spec.md" +--- + +# Task 05: Gate terminal PR auto-start + +Skip auto-start only when all relevant PR rows are terminal. +Persist the result on the task because no session exists. + +- **Acceptance:** + 1. A positive task-repository PR number matches exact `(repository_id, pr_number)` identity. + 2. The fallback trims one documented Git ref prefix and compares branches case-sensitively. + 3. The gate skips launch only when at least one relevant PR exists and all are merged or closed. + 4. Open, empty, unknown, absent, and lookup-error states launch normally. + 5. A terminal sibling PR cannot gate an open current branch. + 6. The gate writes `tasks.metadata["last_launch_error"]` with a stable stamp from relevant PR identity and state. + 7. It offers `mark_review_done` only when the workflow has a valid terminal final step. + 8. Replaying the same gate is a semantic no-op. A later successful launch clears the task record. + 9. Manual launches remain outside the gate. + 10. Set and clear use atomic metadata-key writes. A clear with a stale stamp does not erase a newer error. + +- **Verification:** + `cd apps/backend && go test ./internal/orchestrator/... -race` + +- **Files likely touched:** + `apps/backend/internal/orchestrator/event_handlers_workflow.go`, + `apps/backend/internal/orchestrator/event_handlers_workflow_test.go`, + the narrow GitHub reader and task metadata writer seams used by the orchestrator, + key-scoped repository or service methods with focused concurrency tests. + +- **Dependencies:** Task 01 (`pr_already_closed` category + `mark_review_done` action). +- **Parallelism:** sequential. +- **Inputs:** spec "Relevant PR selection" and "Launch transitions". + +## Results +- Added exact task-repository and PR identity matching, including positive PR + number metadata and normalized branch fallback. +- Added fail-open terminal-state gating for deferred workflow, loaded-step, and + GitHub review auto-start paths. +- Persisted a bounded task-owned error with a stable state-sensitive stamp and + conditional `mark_review_done` action. +- Added compare-and-clear semantics and cleared the task-owned error after a + later successful launch. +- Verification: focused PR-gate and persistence tests passed, including the + SQLite compare-and-clear integration test. diff --git a/docs/plans/task-launch-failure-recovery/task-06-recovery-actions-ws.md b/docs/plans/task-launch-failure-recovery/task-06-recovery-actions-ws.md new file mode 100644 index 0000000000..4aae2726c7 --- /dev/null +++ b/docs/plans/task-launch-failure-recovery/task-06-recovery-actions-ws.md @@ -0,0 +1,74 @@ +--- +id: "06-recovery-actions-ws" +title: "Task launch recovery action" +status: done +wave: 5 +depends_on: ["01-failure-taxonomy-contracts", "02-gitref-default-hardening", "03-worktree-live-default-fallback", "04-launch-failure-classification", "05-pr-review-autostart-gating", "10-task-base-self-heal", "12-remove-legacy-launch-guidance"] +plan: "plan.md" +spec: "../../specs/task-launch-failure-recovery/spec.md" +--- + +# Task 06: Task launch recovery action + +Add a task-scoped WebSocket action for pre-session and session launch errors. + +- **Acceptance:** + 1. `task.launch.recover` implements the exact request and response shapes from the spec. + 2. The handler authorizes the task before it reads any other row. + 3. It requires the active `error_stamp` and rejects stale recovery requests. + 4. It validates optional session ownership and required task-repository ownership. + 5. `retry_default` refreshes the live default, updates both base records, and relaunches. + 6. `pick_base_branch` validates the selected remote branch, updates the exact row, and relaunches. + 7. `mark_review_done` selects only the final step accepted by `IsTerminalStep`. + 8. It rechecks that all relevant PRs are still terminal before the move. + 9. A recovery-only move option permits `FAILED` to become `COMPLETED` at the terminal step. + 10. Normal task moves continue to preserve failed and cancelled task states. + 11. The terminal action is idempotent when the task already occupies the target step. + 12. Invalid ownership, branch, PR, terminal-step, or task/session inputs cause no mutation. + 13. A default-resolution miss records `default_branch_unresolved` with `pick_base_branch`. + 14. Success compares and clears the source stamp. It cannot erase a newer error. + 15. Existing `session.recover` actions do not change. + +- **Verification:** + `cd apps/backend && go test ./internal/orchestrator/... -race` + +- **Files likely touched:** + `apps/backend/pkg/websocket/actions.go`, + `apps/backend/internal/orchestrator/handlers/handlers.go`, + `apps/backend/internal/orchestrator/handlers/handlers_test.go`, + `apps/backend/internal/task/service/service_workflow.go`, + `apps/backend/internal/task/service/service_workflow_test.go`, + the narrow remote-default and task-move interfaces used by that handler. + +- **Dependencies:** Tasks 01, 02, 03, 04, 05, 10, and 12. +- **Parallelism:** sequential. +- **Inputs:** spec "WebSocket action `task.launch.recover`" and "Recovery transitions". + +## Results + +- Added and registered `task.launch.recover` with the exact request and + response shapes. +- Authorized the task before loading session, repository, or workflow rows; + stale stamps, foreign rows, invalid branches, open PRs, and invalid terminal + workflows leave state unchanged. +- Added `retry_default`, `pick_base_branch`, and `mark_review_done` handling, + including live default refresh, exact task-repository writes, terminal PR + rechecks, bounded source compare-and-clear, and typed unresolved-default + replacement. +- Added a recovery-only workflow move option for `FAILED` to `COMPLETED` and + kept normal failed/cancelled move preservation intact. +- Added session metadata compare-and-clear persistence coverage. +- Added a final source reload that rejects an older task or session error + before any recovery mutation. +- Reused one workflow terminal-step traversal helper for the PR gate and + recovery action. +- Verification: `cd apps/backend && go test ./internal/orchestrator/... -race` + passed (2,841 tests in 9 packages). +- PR fixup verification: `cd apps/backend && go test ./internal/orchestrator/... ./internal/common/netutil/... ./internal/task/repository/sqlite/...` + passed (3,468 tests in 11 packages), and `make lint` reported zero issues. + +Second PR fixup hardening added a column-scoped, expected-value guarded default +branch update; it also rejects a relaunch with no session and keeps a successful +relaunch successful when compare-and-clear logging is the only failure. Session +recovery now uses the repository contract directly, and unresolved-default +details are sanitized before task or session persistence. diff --git a/docs/plans/task-launch-failure-recovery/task-07-status-summary-projection.md b/docs/plans/task-launch-failure-recovery/task-07-status-summary-projection.md new file mode 100644 index 0000000000..9c1177128f --- /dev/null +++ b/docs/plans/task-launch-failure-recovery/task-07-status-summary-projection.md @@ -0,0 +1,48 @@ +--- +id: "07-status-summary-projection" +title: "Project bounded launch errors" +status: done +wave: 4 +depends_on: ["01-failure-taxonomy-contracts", "04-launch-failure-classification", "05-pr-review-autostart-gating"] +plan: "plan.md" +spec: "../../specs/task-launch-failure-recovery/spec.md" +--- + +# Task 07: Project bounded launch errors + +Project session-owned and task-owned errors through one bounded status contract. + +- **Acceptance:** + 1. `active_error` gains optional session and task-repository IDs, category, and actions. + 2. Category has a 64-byte UTF-8 bound. Row identity has a 256-byte bound. + 3. Actions accept known values only, keep source order, remove duplicates, and stop at three. + 4. Live projection and rebuild read both persisted error sources. + 5. The newest active record wins by occurrence time, then stable stamp. + 6. Malformed task metadata is ignored and cannot invalidate the full summary. + 7. DTO, API conversion, boot reads, and status-summary events carry the complete shape. + 8. No transcript or unbounded provider payload enters the summary. + +- **Verification:** + `cd apps/backend && go test ./internal/task/... ./internal/orchestrator/... -race` + +- **Files likely touched:** + `apps/backend/internal/task/statussummary/model.go`, + projector and rebuild files under that package, + `apps/backend/internal/task/service/service_status_summary_rebuild.go`, + owning DTO conversion files and focused tests. + +- **Dependencies:** Tasks 01, 04, and 05. +- **Parallelism:** sequential. +- **Inputs:** both affected specs and plan "Bounded status projection". + +## Results + +- Extended the bounded active-error contract with task/session ownership, + category, repository row, stamp, and normalized recovery actions. +- Added authoritative rebuild and live projector handling for both task-owned + and session-owned errors, including malformed-task metadata preservation and + deterministic timestamp/stamp ties. +- Updated task-service rebuild and gateway conversion paths, with focused + bounds, action, malformed-source, and task-error lifecycle tests. +- Verification: `cd apps/backend && go test ./internal/task/... ./internal/orchestrator/... -race` + passed (5,749 tests in 20 packages). diff --git a/docs/plans/task-launch-failure-recovery/task-08-frontend-failure-surface-and-recovery.md b/docs/plans/task-launch-failure-recovery/task-08-frontend-failure-surface-and-recovery.md new file mode 100644 index 0000000000..4dd94d1c74 --- /dev/null +++ b/docs/plans/task-launch-failure-recovery/task-08-frontend-failure-surface-and-recovery.md @@ -0,0 +1,55 @@ +--- +id: "08-frontend-failure-surface-and-recovery" +title: "Frontend launch-error data" +status: done +wave: 5 +depends_on: ["07-status-summary-projection"] +plan: "plan.md" +spec: "../../specs/task-launch-failure-recovery/spec.md" +--- + +# Task 08: Frontend launch-error data + +Carry the bounded launch-error contract into task-detail data and chat view models. + +- **Acceptance:** + 1. Frontend status, office-task, `LastAgentError`, and `RunError` types carry the bounded fields. + 2. Task-detail mapping preserves `status_summary`. + 3. A tested selector chooses the newest summary from detail data and live task caches. + 4. Session metadata parsing keeps exact row identity, action order, and explicit error stamp. + 5. A chat helper suppresses a summary error when a matching failed-session error exists. + 6. Task-owned errors with no session remain eligible for standalone rendering. + +- **Verification:** + `cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- lib/session-last-agent-error.test.ts lib/task-status-summary.test.ts components/task/simple/chat-entries.test.ts && cd web && pnpm run typecheck` + +- **Files likely touched:** + `apps/web/lib/types/task-status-summary.ts`, + `apps/web/lib/session-last-agent-error.ts`, + `apps/web/lib/task-status-summary.ts`, + `apps/web/lib/state/slices/office/types.ts`, + `apps/web/app/office/tasks/[id]/types.ts`, + `apps/web/app/office/tasks/[id]/page.tsx`, + `apps/web/components/task/simple/chat-entries.ts`, + focused tests beside those files. + +- **Dependencies:** Task 07. +- **Parallelism:** sequential. +- **Inputs:** spec "Bounded task projection" and plan "Types and live summary selection". + +## Results +Implemented the bounded task summary types, task-scoped error context, and the standalone recovery card. +The card preserves exact task-repository identity and coalesces duplicate recovery requests. +It also renders typed errors without actions, guards refreshes by summary +revision, and shows pending state in duplicate task surfaces. + +Verification: + +- `cd apps/web && pnpm exec vitest run components/task/simple/components/task-launch-error-entry.test.tsx components/task/simple/task-chat.test.tsx lib/task-status-summary.test.ts lib/session-last-agent-error.test.ts`: 4 files and 50 tests passed. +- `cd apps/web && pnpm run typecheck`: passed. +- `cd apps/web && pnpm run lint`: passed. +- PR fixup verification: focused tests passed in 3 files with 64 tests. + +Second PR fixup hardening requires equal session error stamps before suppressing +a summary card and keeps non-typed status errors on the normal responsive +layout path. diff --git a/docs/plans/task-launch-failure-recovery/task-09-e2e-and-i18n.md b/docs/plans/task-launch-failure-recovery/task-09-e2e-and-i18n.md new file mode 100644 index 0000000000..0fedb51818 --- /dev/null +++ b/docs/plans/task-launch-failure-recovery/task-09-e2e-and-i18n.md @@ -0,0 +1,50 @@ +--- +id: "09-e2e-and-i18n" +title: "Localized recovery E2E" +status: done +wave: 7 +depends_on: ["11-responsive-launch-error-surface"] +plan: "plan.md" +spec: "../../specs/task-launch-failure-recovery/spec.md" +--- + +# Task 09: Localized recovery E2E + +Prove the desktop and phone outcomes against fresh production assets. +Complete all five locale catalogs. + +- **Acceptance:** + 1. The desktop spec proves the pre-session gate, reload persistence, terminal move, and zero session launch. + 2. It proves exact-row branch recovery, relaunch, persisted self-heal, and pointer-toast copy. + 3. The old PR missing-branch spec expects the typed card, not a warning message or archive/delete actions. + 4. The mobile spec proves the same branch outcome through `MobilePickerSheet`. + 5. The mobile test checks 44px targets, viewport containment, picker scroll, and no document overflow. + 6. Tests use stable test IDs, UI assertions, causal WS waits, and disposable seeded state. + 7. New copy exists in all five locale catalogs. + 8. Traditional Chinese catalogs come from `pnpm run i18n:zh-hant`. + 9. `pnpm run i18n:check` passes. + +- **Verification:** + `cd apps && pnpm install --frozen-lockfile && cd web && pnpm run i18n:check && pnpm e2e:run tests/pr/pr-watcher-missing-branch.spec.ts tests/task/launch-failure-recovery.spec.ts && pnpm e2e:run --project mobile-chrome tests/task/mobile-launch-failure-recovery.spec.ts` + +- **Files likely touched:** + `apps/web/e2e/tests/task/launch-failure-recovery.spec.ts`, + `apps/web/e2e/tests/task/mobile-launch-failure-recovery.spec.ts`, + `apps/web/e2e/tests/pr/pr-watcher-missing-branch.spec.ts`, + `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/task.json`. + +- **Dependencies:** Task 11. +- **Parallelism:** sequential. +- **Inputs:** plan "E2E Tests", spec scenarios, `/e2e`, `/mobile-parity`, and root i18n rules. + +## Results +Implemented desktop and mobile recovery coverage and updated the deleted-branch watcher cases. +All new task copy is present in the five locale catalogs. + +Verification: + +- `cd apps/web && pnpm run i18n:check`: passed. The catalogs contain 7,159 referenced keys and complete translations. +- `cd apps/web && pnpm e2e:run --host --no-build tests/task/launch-failure-recovery.spec.ts`: 2 tests passed. +- `cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome tests/task/mobile-launch-failure-recovery.spec.ts`: 1 test passed. +- Desktop and mobile PR watcher specs: 1 test passed in each project. +- Desktop and mobile recovery screenshots were captured and inspected. The picker closes after selection, and no recovery failure toast remains. diff --git a/docs/plans/task-launch-failure-recovery/task-10-task-base-self-heal.md b/docs/plans/task-launch-failure-recovery/task-10-task-base-self-heal.md new file mode 100644 index 0000000000..94db24f4cd --- /dev/null +++ b/docs/plans/task-launch-failure-recovery/task-10-task-base-self-heal.md @@ -0,0 +1,52 @@ +--- +id: "10-task-base-self-heal" +title: "Carry task-repository launch identity" +status: done +wave: 2 +depends_on: ["01-failure-taxonomy-contracts"] +plan: "plan.md" +spec: "../../specs/task-launch-failure-recovery/spec.md" +--- + +# Task 10: Carry task-repository launch identity + +Carry exact task-repository identity through launch preparation. +Use that identity for failure targeting and fallback self-heal. + +## Design constraint + +The worktree manager remains task-agnostic. +The orchestrator owns task writes after lifecycle returns the exact row identity and resolved branch. + +- **Acceptance:** + 1. `TaskRepositoryID` flows through every request and result type listed in `plan.md`. + 2. Fresh launch, resume, single-repository synthesis, reuse, and multi-repository mapping preserve it. + 3. Same-repository multi-branch results keep distinct task-repository IDs. + 4. A failed per-repository result identifies the exact row without branch-string correlation. + 5. The orchestrator calls `UpdateRepositoryBaseBranch` only after fallback and a changed branch. + 6. No write occurs for an existing base, empty resolved base, or unchanged base. + 7. A write error logs a warning and does not fail the launch. + 8. Only the recovered row changes in multi-repository and multi-branch tasks. + 9. Lifecycle and worktree packages add no task-service dependency or setter. + +- **Verification:** + `cd apps/backend && go test ./internal/orchestrator/executor/... ./internal/agent/runtime/lifecycle/... ./internal/task/service/... -race` + +- **Files likely touched:** + `apps/backend/internal/orchestrator/executor/executor.go`, + `apps/backend/internal/orchestrator/executor/executor_resume.go`, + `apps/backend/internal/orchestrator/executor/executor_execute.go`, + `apps/backend/internal/agent/runtime/lifecycle/types.go`, + `apps/backend/internal/agent/runtime/lifecycle/env_preparer.go`, + `apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go`, + owning mapping and multi-repository tests. + +- **Dependencies:** Task 01. +- **Parallelism:** sequential. +- **Inputs:** spec "Session-owned launch error" and "Persistence guarantees". + Reuse `Service.UpdateRepositoryBaseBranch` from `service_branch_update.go`. + +## Results +- Threaded exact `TaskRepositoryID` through executor, lifecycle, adapter, resume, reuse, synthesis, and multi-repository worktree result paths. +- Added fallback result fields and an optional orchestrator-owned task-service seam that updates only changed, identified fallback rows; write failures are warnings. +- Verification: `go test ./internal/orchestrator/executor/... ./internal/agent/runtime/lifecycle/... ./internal/task/service/... -race` passed. diff --git a/docs/plans/task-launch-failure-recovery/task-11-responsive-launch-error-surface.md b/docs/plans/task-launch-failure-recovery/task-11-responsive-launch-error-surface.md new file mode 100644 index 0000000000..aac1608217 --- /dev/null +++ b/docs/plans/task-launch-failure-recovery/task-11-responsive-launch-error-surface.md @@ -0,0 +1,50 @@ +--- +id: "11-responsive-launch-error-surface" +title: "Responsive launch-error surface" +status: done +wave: 6 +depends_on: ["06-recovery-actions-ws", "08-frontend-failure-surface-and-recovery"] +plan: "plan.md" +spec: "../../specs/task-launch-failure-recovery/spec.md" +--- + +# Task 11: Responsive launch-error surface + +Render one persistent launch-error card on desktop and phone. +Connect it to the task-scoped recovery action. + +- **Acceptance:** + 1. `TaskChat` shows `TaskLaunchErrorEntry` before its empty state. + 2. `RunErrorEntry` reuses the card without changing existing session recovery behavior. + 3. One source error renders one card after summary and session data converge. + 4. Actions send exact payloads with task, session, row, action, branch, and stamp fields. + 5. Desktop uses the branch popover pattern. Phone uses `MobilePickerSheet`. + 6. Actions wrap without page overflow and provide at least 44px touch targets. + 7. The picker owns internal scrolling, safe-area clearance, focus return, and dismissal. + 8. The pointer toast contains no raw Git output. All new copy uses `t()`. + +- **Verification:** + `cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- components/task/simple/components/task-launch-error-entry.test.tsx components/task/simple/components/run-error-entry.test.tsx components/task/simple/task-chat.test.tsx && cd web && pnpm run typecheck` + +- **Files likely touched:** + `apps/web/components/task/simple/chat-activity-tabs.tsx`, + `apps/web/components/task/simple/task-chat.tsx`, + `apps/web/components/task/simple/components/task-launch-error-entry.tsx`, + `apps/web/components/task/simple/components/run-error-entry.tsx`, + `apps/web/components/task/base-branch-picker.tsx`, + a shared responsive branch-picker helper beside those components, + the task-launch toast call site and focused tests. + +- **Dependencies:** Tasks 06 and 08. +- **Parallelism:** sequential. +- **Inputs:** spec "Desktop and mobile behavior" and mobile exemplar `MobilePickerSheet`. + +## Results +Implemented the shared recovery card, desktop branch popover, and mobile branch sheet. +Recovery controls wrap on narrow screens, use 44px touch targets, and close the picker before the request completes. + +Verification: + +- `cd apps/web && pnpm exec vitest run components/task/simple/components/task-launch-error-entry.test.tsx components/task/simple/components/run-error-entry.test.tsx components/task/simple/task-chat.test.tsx`: 2 files and 24 tests passed. +- Desktop and mobile recovery E2E tests passed. +- Desktop and mobile screenshots show the recovery card without page overflow or a stale recovery toast. diff --git a/docs/plans/task-launch-failure-recovery/task-12-remove-legacy-launch-guidance.md b/docs/plans/task-launch-failure-recovery/task-12-remove-legacy-launch-guidance.md new file mode 100644 index 0000000000..b5e57e129b --- /dev/null +++ b/docs/plans/task-launch-failure-recovery/task-12-remove-legacy-launch-guidance.md @@ -0,0 +1,45 @@ +--- +id: "12-remove-legacy-launch-guidance" +title: "Remove legacy launch guidance" +status: done +wave: 4 +depends_on: ["04-launch-failure-classification"] +plan: "plan.md" +spec: "../../specs/task-launch-failure-recovery/spec.md" +--- + +# Task 12: Remove legacy launch guidance + +Remove the warning-message path that conflicts with typed launch errors. +Keep unrelated recovery and toast behavior. + +- **Acceptance:** + 1. Handled missing-branch errors create no warning message or archive/delete action metadata. + 2. The handled path writes no `missing_pr_branch_recovery_claimed` key. + 3. The handled path does not set `suppressToast`, so the pointer toast remains available. + 4. Unrelated auth, resume, and bootstrap toast suppression stays unchanged. + 5. Existing missing-branch tests now assert the typed-error ownership boundary. + +- **Verification:** + `cd apps/backend && go test ./internal/orchestrator/... -race` + +- **Files likely touched:** + `apps/backend/internal/orchestrator/service.go`, + `apps/backend/internal/orchestrator/task_launch_failure_test.go`, + related task-operation tests that assert the old claim or message. + +- **Dependencies:** Task 04. +- **Parallelism:** sequential. +- **Inputs:** spec "Failure modes" and plan "Legacy launch-guidance removal". + +## Results + +- Removed the handled launch-failure warning, archive/delete actions, toast + suppression, and legacy `missing_pr_branch_recovery_claimed` write. +- Removed the unused compatibility callback so older integrations cannot + recreate the removed guidance path. +- Updated the focused launch-failure tests to assert the executor ownership + boundary. Early workspace failures remain neutral until executor + classification exists. +- Verification: `cd apps/backend && go test ./internal/orchestrator/... -race` + passed (2,841 tests in 9 packages). diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index d7c62cc6db..e733b3b4ff 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -306,6 +306,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance, | [board-step-visibility-filter](board-step-visibility-filter/spec.md) | draft | | [shutdown-turn-failure-suppression](shutdown-turn-failure-suppression/spec.md) | draft | | [executor-profile-env-precedence](executor-profile-env-precedence/spec.md) | building | +| [task-launch-failure-recovery](task-launch-failure-recovery/spec.md) | draft | --- diff --git a/docs/specs/platform/bounded-task-status-delivery.md b/docs/specs/platform/bounded-task-status-delivery.md index 48f1c36a80..d076a01874 100644 --- a/docs/specs/platform/bounded-task-status-delivery.md +++ b/docs/specs/platform/bounded-task-status-delivery.md @@ -1,7 +1,7 @@ --- status: approved created: 2026-08-01 -updated: 2026-08-17 +updated: 2026-08-19 owner: kandev --- @@ -56,7 +56,7 @@ The initial contract is: | `primary_session` | Primary session ID and lifecycle state | One session | | `foreground_activity`, `active_subagent_count` | Existing task-level busy aggregate | Constant | | `pending_action` | `permission`, `clarification`, or absent | Constant | -| `active_error` | Session ID, stable error stamp, occurrence time, and safe preview | One preview, at most 512 UTF-8 bytes | +| `active_error` | Optional session and task-repository IDs, stamp, time, preview, category, and actions | One preview and at most three known actions | | `git` | Aggregate additions, deletions, changed files, ahead, and behind | Numeric totals only | | `pull_request` | Count, bounded representative identity, and aggregate display state | Constant regardless of PR count | @@ -99,8 +99,15 @@ remain during migration, but switchers use the summary when present. session, Git, and pull-request observations before deriving the new row. The event therefore updates one keyed observation without discarding unchanged siblings from other keys. - `active_error` is independent of the primary state icon. It represents the - newest relevant recoverable agent error and clears after an authoritative - dismissal or a newer agent response according to the existing error rules. + newest relevant session error or task-owned pre-session launch error. +- `active_error.session_id` is optional for a task-owned error. + `task_repository_id` identifies one exact recovery target when it exists. +- `active_error.preview` has at most 512 UTF-8 bytes. + `category` has at most 64 UTF-8 bytes. +- `active_error.recovery_actions` contains only known action values. + The projector removes duplicates, preserves order, and keeps at most three values. +- A session error clears after an authoritative dismissal or a newer agent response. + A task-owned launch error clears after a successful launch, recovery, or terminal move. - Successful session deletion is an authoritative removal of that session's error. The summary removes the error without a backend restart or another session event. @@ -250,6 +257,8 @@ intermediate replacement. database or metadata error, it logs the read failure and keeps the existing recovery, state-reconciliation, and cleanup behavior. Only an authoritative missing-session result suppresses those side effects. +- If task-owned launch-error metadata is malformed, the projector ignores that + record. It does not reject or erase the last valid summary. ## Scenarios @@ -269,6 +278,10 @@ intermediate replacement. - **GIVEN** a recoverable error is dismissed or followed by a newer agent response, **WHEN** the projector processes that occurrence, **THEN** the independent error indicator clears on both task switchers. +- **GIVEN** auto-start stops before it creates a session, **WHEN** the task stores + a `last_launch_error`, **THEN** `active_error` carries it without a session ID. +- **GIVEN** a task-owned error and a session-owned error, **WHEN** the summary + rebuilds, **THEN** `active_error` contains the newer active record. - **GIVEN** a stopped session owns the task's `active_error`, **WHEN** the user deletes that session, **THEN** desktop and mobile task switchers remove its error indicator while the task and replacement sessions remain available. diff --git a/docs/specs/task-launch-failure-recovery/spec.md b/docs/specs/task-launch-failure-recovery/spec.md new file mode 100644 index 0000000000..13cf2fb82c --- /dev/null +++ b/docs/specs/task-launch-failure-recovery/spec.md @@ -0,0 +1,275 @@ +--- +status: draft +created: 2026-08-19 +owner: cfl12 +--- + +# Task Launch Failure Recovery + +## Why + +A task launch can fail after its base branch disappears or its stored default branch becomes stale. +Today, the user sees a transient raw Git error and no durable recovery path. + +PR-review automation can also launch work after the relevant PR is complete. +This wastes an agent run and can create a misleading failed task. + +## What + +- A workflow `on_enter` auto-start does not launch work when every relevant GitHub PR is terminal. +- The gate keeps the task in its current workflow step and records a durable informational reason. +- A manual launch always bypasses the PR gate. +- Every handled launch error has a typed category, a safe message, and an ordered set of recovery actions. +- The task surface shows the error after reload. The launch toast points the user to that surface. +- A stale repository default resolves from the live remote default before the launch stops. +- A successful fallback writes the resolved branch to the exact `task_repositories` row. +- Recovery actions target one task-repository row. Multi-repository and multi-branch tasks stay unambiguous. +- Desktop and mobile provide the same recovery outcomes. + +## Data model + +### Session-owned launch error + +A launch that created a session stores its error in `task_sessions.metadata["last_agent_error"]`. +The existing `LastAgentError` record gains two fields. + +```text +LastAgentError + message string safe user-readable message + occurred_at timestamp + agent_execution_id string optional + code string stable failure category + details string optional bounded detail + recovery_actions []string ordered known values, maximum 3 + task_repository_id string optional exact failed row + stamp string optional bounded identity for typed launch errors + dismissed_at timestamp optional +``` + +### Task-owned pre-session launch error + +An auto-start gate runs before a session exists. +It stores an informational error in `tasks.metadata["last_launch_error"]`. + +```text +TaskLaunchError + message string safe user-readable message + occurred_at timestamp + code string stable failure category + details string optional bounded safe diagnostic detail + recovery_actions []string ordered known values, maximum 3 + task_repository_id string optional exact affected row + stamp string stable identity for idempotent replay +``` + +The gate derives `stamp` from the sorted relevant PR identities and states. +The gate preserves the record and its timestamp when the same stamp already exists. + +The failure categories are: + +- `base_branch_missing` +- `pr_already_closed` +- `default_branch_unresolved` +- `generic_launch_failure` + +The recovery actions are: + +- `retry_default` +- `pick_base_branch` +- `mark_review_done` + +### Bounded task projection + +`TaskStatusSummary.active_error` projects the newest active task-owned or session-owned error. +It has this wire shape: + +```text +active_error + session_id string optional for a task-owned error + task_repository_id string optional exact affected row + stamp string + occurred_at timestamp + preview string maximum 512 UTF-8 bytes + category string maximum 64 UTF-8 bytes + recovery_actions []string known values only, deduplicated, maximum 3 +``` + +The projector ignores unknown recovery actions. +It preserves the declared action order after it removes duplicates. +Malformed metadata cannot make the full summary invalid. + +GitHub PR state remains in `github_task_prs`. +Task-repository PR identity remains in `task_repositories.metadata["pr_number"]`. +This feature adds no database column or table. + +## API surface + +### WebSocket action `task.launch.recover` + +The new action accepts this payload: + +```json +{ + "task_id": "task-id", + "session_id": "optional-failed-session-id", + "task_repository_id": "required-for-branch-actions", + "action": "retry_default | pick_base_branch | mark_review_done", + "base_branch": "required-only-for-pick_base_branch", + "error_stamp": "required-current-active-error-stamp" +} +``` + +The success response is: + +```json +{ + "ok": true, + "task_id": "task-id", + "session_id": "optional-new-or-reused-session-id" +} +``` + +The handler authorizes `task_id` first. +If `session_id` exists, the handler proves that the session belongs to the task. +If `task_repository_id` exists, the handler proves that the row belongs to the task. +The handler rejects a request when `error_stamp` does not match the current active error. + +`retry_default` and `pick_base_branch` require `task_repository_id`. +`mark_review_done` does not require a session or repository row. +The existing `session.recover` action and its action values do not change. + +### Status delivery + +Boot state, task reads, and `task.status_summary.updated` carry the extended `active_error`. +The contract remains a complete replacement value with a monotonic revision. + +### Default-branch resolution + +`gitref.DefaultBranchOrEmpty` returns empty when only the current local `HEAD` branch exists. +It stays a pure local-ref helper and does not run a network command. + +The worktree manager owns live remote-default refresh through +`Manager.ResolveRemoteDefaultBranch(ctx, repoPath)`. +It first reads `refs/remotes/origin/HEAD`. +If necessary, it runs one bounded, noninteractive `git remote set-head origin --auto` command. + +## State machine + +### Relevant PR selection + +The auto-start gate compares each `task_repositories` row with active PR links for the task. + +1. A positive metadata `pr_number` matches the exact `(repository_id, pr_number)` PR. +2. Without that identity, an exact normalized `(repository_id, checkout_branch, head_branch)` match applies. +3. A PR for a different repository or checkout branch is not relevant. + +Branch normalization trims whitespace and one leading Git ref prefix. +The accepted prefixes are `refs/heads/`, `refs/remotes/origin/`, and `origin/`. +The remaining branch comparison is case-sensitive. + +The gate skips launch only when at least one relevant PR exists and every relevant PR is `merged` or `closed`. +An `open`, empty, or unknown state keeps the normal launch path. +This rule makes an open relevant PR win over a terminal sibling PR. + +### Launch transitions + +| Trigger | Condition | Outcome | +| --- | --- | --- | +| Workflow `on_enter` | All relevant PRs are terminal | No session or worktree starts. The task keeps its step and stores `pr_already_closed`. | +| Workflow `on_enter` | A relevant PR is open or unknown | The launch proceeds. | +| Workflow `on_enter` | No relevant PR exists or lookup fails | The launch proceeds. | +| Manual launch | Any PR state | The launch proceeds. | +| Launch preparation | The selected base and all fallbacks are missing | The session and task enter their existing failed states. The session stores `base_branch_missing`. | +| Launch preparation | A valid fallback resolves | The launch continues. The exact task-repository base branch self-heals. | + +A successful later launch clears `tasks.metadata["last_launch_error"]`. +A failed manual launch replaces the projected error only when its occurrence time is newer. + +### Recovery transitions + +- `retry_default` resolves the live remote default for the targeted row. + It writes the repository default and the task-repository base, then relaunches. +- `pick_base_branch` validates the selected remote branch for the targeted row. + It writes the task-repository base, then relaunches. +- `mark_review_done` lists the workflow steps in position order. + It selects the final step only when `workflow/models.IsTerminalStep(final, nil)` returns true. + It also requires the relevant PR selection to contain only terminal states. + It moves the task through the normal task move service, including history, WIP, state, and events. + A recovery-only move option permits `FAILED` to become `COMPLETED` at that terminal step. + Normal manual moves continue to preserve failed and cancelled states. + +The UI offers `mark_review_done` only when the workflow and PR conditions are valid. +A forged request fails when either condition is invalid. +The action is idempotent when the task already occupies that terminal step. + +## Failure modes + +- If the PR lookup fails, the gate logs the error and launches normally. +- If no PR link matches a task-repository row, the gate launches normally. +- If relevant PR rows contain conflicting states, any open or unknown state launches normally. +- If live remote-default refresh times out, the error stays distinct from an unresolved default. +- If live remote-default refresh has an auth or network error, the typed cause remains available for logs. +- If no remote default resolves, the UI shows `default_branch_unresolved` and offers `pick_base_branch`. +- If branch existence cannot be determined, the error remains a verification error, not a missing-branch error. +- If task-repository self-heal fails, the launch continues and logs a warning. +- If repository identity is empty or ambiguous, the backend omits repository-scoped recovery actions. +- If the workflow has no valid terminal step, the backend omits `mark_review_done`. +- If a relevant PR is open or unknown, the backend omits `mark_review_done`. +- If a recovery request names a foreign session or repository row, the request fails without mutation. +- If a recovery request names a stale error stamp, the request fails without mutation. + +The typed task error replaces the old missing-branch warning message and its archive/delete actions. +The old `missing_pr_branch_recovery_claimed` metadata claim no longer applies to handled launch errors. +The handled path does not suppress the new pointer toast. + +## Persistence guarantees + +Session-owned errors survive restart in `task_sessions.metadata`. +Pre-session gate outcomes survive restart in `tasks.metadata`. +The status projection rebuild reads both sources and selects the newest active error. + +A successful recovery clears the source error after the required write and relaunch or move succeeds. +A failed recovery updates the same source with the new category and available actions. +No successful branch write is rolled back after a later launch error. + +The corrected repository default persists in `repositories.default_branch`. +The corrected task base persists in the exact `task_repositories.base_branch` row. + +## Desktop and mobile behavior + +The task Chat surface shows one persistent launch-error card before the empty-state message. +The card uses the same projection and action handler on all viewports. +If a matching failed session renders the card, the summary path does not render a duplicate. + +Desktop shows recovery buttons inline and opens the existing branch picker pattern. +Phone layouts keep the card inline and wrap actions without horizontal page overflow. +The branch choice uses `MobilePickerSheet`, with the task Chat surface as its entry point. + +Each new recovery control has a touch target of at least 44px. +The task Chat surface remains the only vertical scroll owner. +The picker owns its internal list scroll and clears the bottom safe area. + +## Scenarios + +- **GIVEN** a task row linked to one merged PR, **WHEN** `on_enter` auto-start runs, **THEN** no session starts and the task shows `pr_already_closed` after reload. +- **GIVEN** a task with an open PR and a terminal PR, **WHEN** auto-start runs, **THEN** the launch proceeds for the open PR. +- **GIVEN** one repository with two task branches, **WHEN** only the sibling branch PR is terminal, **THEN** the current branch launch proceeds. +- **GIVEN** a PR lookup error or no relevant PR, **WHEN** auto-start runs, **THEN** the launch proceeds. +- **GIVEN** absent selected and fallback bases, **WHEN** the live default resolves, **THEN** launch continues and the exact row self-heals. +- **GIVEN** no usable base or default, **WHEN** launch preparation stops, **THEN** the failed session shows `base_branch_missing` with unambiguous actions. +- **GIVEN** one multi-repository row with a missing base, **WHEN** the user selects another base, **THEN** only that row changes before relaunch. +- **GIVEN** a valid terminal step, **WHEN** the user selects Mark review done, **THEN** the move completes the task without launching. +- **GIVEN** a reopened relevant PR, **WHEN** the user sends a stale Mark review done action, **THEN** no task mutation occurs. +- **GIVEN** no valid terminal workflow step, **WHEN** the task error renders, **THEN** Mark review done is absent. +- **GIVEN** a local import with only a feature-branch `HEAD`, **WHEN** default detection runs, **THEN** the stored default stays empty. +- **GIVEN** a handled launch error, **WHEN** the browser receives it, **THEN** the toast points to task details without raw Git output. +- **GIVEN** recovery on a phone, **WHEN** the user chooses a base, **THEN** the task relaunches without horizontal page overflow. + +## Out of scope + +- A background poller for all repository defaults. +- A bulk repair of existing repository-default values. +- A PR gate for manual launches. +- PR gating for providers other than GitHub. +- Changes to ACP resume semantics. +- A different self-heal target for stacked PRs.