diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go
index 05688fe140..112d923a0a 100644
--- a/apps/backend/internal/backendapp/adapters.go
+++ b/apps/backend/internal/backendapp/adapters.go
@@ -1432,6 +1432,27 @@ func (a *messageCreatorAdapter) CreateSessionMessage(ctx context.Context, taskID
return err
}
+// CreateSessionMessageIdempotent persists a lifecycle/status message with a
+// deterministic ID so a replayed failure event cannot add another transcript
+// entry for the same failure stamp.
+func (a *messageCreatorAdapter) CreateSessionMessageIdempotent(
+ ctx context.Context,
+ messageID, taskID, content, agentSessionID, messageType, turnID string,
+ metadata map[string]interface{}, requestsInput bool,
+) error {
+ _, err := a.svc.CreateMessageIdempotent(ctx, messageID, &taskservice.CreateMessageRequest{
+ TaskSessionID: agentSessionID,
+ TaskID: taskID,
+ TurnID: turnID,
+ Content: content,
+ AuthorType: "agent",
+ Type: messageType,
+ Metadata: metadata,
+ RequestsInput: requestsInput,
+ })
+ return err
+}
+
// CreatePermissionRequestMessage creates a message for a permission request
func (a *messageCreatorAdapter) CreatePermissionRequestMessage(ctx context.Context, taskID, sessionID, requestID, pendingID, toolCallID, title, turnID string, options []map[string]interface{}, actionType string, actionDetails map[string]interface{}) (string, error) {
metadata := map[string]interface{}{
diff --git a/apps/backend/internal/backendapp/gateway.go b/apps/backend/internal/backendapp/gateway.go
index 32ae225055..28e64c797a 100644
--- a/apps/backend/internal/backendapp/gateway.go
+++ b/apps/backend/internal/backendapp/gateway.go
@@ -510,6 +510,7 @@ func loadTaskSessionObservations(
}
if lastError, ok := models.LoadLastAgentError(session.Metadata); ok && !lastError.IsDismissed() {
input.ActiveError = &statussummary.ActiveErrorSummary{
+ Scope: models.ErrorScopeSession,
SessionID: session.ID,
TaskRepositoryID: lastError.TaskRepositoryID,
ExecutionID: lastError.ExecutionID,
@@ -551,10 +552,13 @@ func loadTaskLaunchErrorObservation(
return statussummary.TaskLaunchErrorObservation{
Observed: true,
Error: &statussummary.ActiveErrorSummary{
+ Scope: models.ErrorScopeTask,
+ SessionID: errorValue.SessionID,
TaskRepositoryID: errorValue.TaskRepositoryID,
Stamp: errorValue.Stamp(),
OccurredAt: errorValue.OccurredAt,
Preview: errorValue.Message,
+ Details: errorValue.Details,
Category: errorValue.Code,
RecoveryActions: errorValue.RecoveryActions,
},
diff --git a/apps/backend/internal/integration/test_server_test.go b/apps/backend/internal/integration/test_server_test.go
index 8ae9782704..1eb8cded24 100644
--- a/apps/backend/internal/integration/test_server_test.go
+++ b/apps/backend/internal/integration/test_server_test.go
@@ -230,6 +230,20 @@ func (a *testMessageCreatorAdapter) CreateSessionMessage(ctx context.Context, ta
return err
}
+func (a *testMessageCreatorAdapter) CreateSessionMessageIdempotent(ctx context.Context, messageID, taskID, content, agentSessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error {
+ _, err := a.svc.CreateMessageIdempotent(ctx, messageID, &taskservice.CreateMessageRequest{
+ TaskSessionID: agentSessionID,
+ TaskID: taskID,
+ TurnID: turnID,
+ Content: content,
+ AuthorType: "agent",
+ Type: messageType,
+ Metadata: metadata,
+ RequestsInput: requestsInput,
+ })
+ return err
+}
+
func (a *testMessageCreatorAdapter) CreatePermissionRequestMessage(ctx context.Context, taskID, sessionID, requestID, pendingID, toolCallID, title, turnID string, options []map[string]interface{}, actionType string, actionDetails map[string]interface{}) (string, error) {
metadata := map[string]interface{}{
"request_id": requestID,
diff --git a/apps/backend/internal/orchestrator/event_handlers_agent.go b/apps/backend/internal/orchestrator/event_handlers_agent.go
index 46080b6d56..80a95176a3 100644
--- a/apps/backend/internal/orchestrator/event_handlers_agent.go
+++ b/apps/backend/internal/orchestrator/event_handlers_agent.go
@@ -21,6 +21,7 @@ import (
"github.com/kandev/kandev/internal/orchestrator/watcher"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/plancomments"
+ "github.com/kandev/kandev/internal/worktree"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -2729,12 +2730,11 @@ func (s *Service) handleRecoverableFailureLockedState(ctx context.Context, data
s.completeTurnForSession(ctx, data.SessionID)
s.persistLastAgentError(ctx, data)
- // Create a status message with recovery action metadata.
- // Skipped for office sessions: the office task page renders its
- // own structured RunErrorEntry sourced from the FAILED session,
- // and including the legacy ActionMessage would double-show the
- // red banner (top-level + inside the embedded chat panel).
- if s.messageCreator != nil && !s.isOfficeSession(ctx, data.SessionID) {
+ // Create a status message with recovery action metadata. Session failures
+ // are chronological transcript entries for every task surface, including
+ // Office sessions. Only the current metadata record owns recovery controls;
+ // the persisted message remains as history after it is retired.
+ if s.messageCreator != nil {
s.createRecoveryStatusMessage(ctx, data)
}
@@ -2796,17 +2796,12 @@ func (s *Service) handleRecoverableFailureLockedState(ctx context.Context, data
}
func (s *Service) persistLastAgentError(ctx context.Context, data watcher.AgentEventData) error {
- errMsg := data.ErrorMessage
- if errMsg == "" {
- errMsg = defaultAgentFailedMessage
- }
+ errMsg := agentFailureMessage(data)
details := routingerr.Sanitize(data.FailureDetails)
- // Keep this metadata until the user dismisses the UI notice locally or a
- // later recoverable failure replaces it. A successful turn should not erase
- // the investigation breadcrumb that explains why the task was marked REVIEW.
lastErr := models.LastAgentError{
Message: errMsg,
OccurredAt: time.Now().UTC(),
+ Scope: models.ErrorScopeSession,
AgentExecutionID: data.AgentExecutionID,
ExecutionID: data.AgentExecutionID,
Phase: data.Phase,
@@ -2815,7 +2810,7 @@ func (s *Service) persistLastAgentError(ctx context.Context, data watcher.AgentE
RemediationURL: providerRemediationURL(data),
Code: data.FailureCode,
Details: details,
- StampValue: data.ErrorStamp,
+ StampValue: agentFailureStamp(data),
}
if err := s.repo.SetSessionMetadataKey(ctx, data.SessionID, models.SessionMetaKeyLastAgentError, lastErr); err != nil {
s.logger.Warn("failed to persist last agent error",
@@ -2829,6 +2824,7 @@ func (s *Service) persistLastAgentError(ctx context.Context, data watcher.AgentE
"task_id": data.TaskID,
"session_id": data.SessionID,
"active": true,
+ "scope": models.ErrorScopeSession,
"message": lastErr.Message,
"occurred_at": lastErr.OccurredAt.Format(time.RFC3339Nano),
"stamp": lastErr.Stamp(),
@@ -2868,39 +2864,160 @@ func (s *Service) persistLastAgentError(ctx context.Context, data watcher.AgentE
return nil
}
-// clearRecoveredAgentError drops a session's stored agent failure once the agent
-// completes a turn, and publishes the inactive error event so open clients drop
-// the red error affordance.
-//
-// persistLastAgentError deliberately keeps the record across a successful turn
-// as an investigation breadcrumb, but nothing ever retired it, so a failure the
-// agent recovered from weeks ago still read as live: every path that re-derives
-// task status from session metadata (a status-summary rebuild, a backend
-// restart, a later session event) put it straight back. The failure also lands
-// in the transcript as a recovery message, which is where an investigation
-// actually looks, so the metadata copy is not the durable record.
-//
-// Writes JSON null rather than a delete: LoadLastAgentError already treats that
-// as absent, so no new repository surface is needed.
+func agentFailureMessage(data watcher.AgentEventData) string {
+ if strings.TrimSpace(data.ErrorMessage) != "" {
+ return data.ErrorMessage
+ }
+ return defaultAgentFailedMessage
+}
+
+func agentFailureStamp(data watcher.AgentEventData) string {
+ if stamp := strings.TrimSpace(data.ErrorStamp); stamp != "" {
+ return stamp
+ }
+ return models.StableLaunchErrorStamp(
+ data.TaskID,
+ data.SessionID,
+ data.AgentExecutionID,
+ data.AttemptID,
+ data.FailureCode,
+ agentFailureMessage(data),
+ )
+}
+
+// launchFailureIsTaskOwned reports whether the launch producer proved that a
+// failure affects the task's shared workspace or workflow. Generic launch
+// errors remain session-owned so a profile-specific startup failure cannot
+// surface recovery controls on another session.
+func launchFailureIsTaskOwned(lastError models.LastAgentError, launchErr error) bool {
+ if lastError.Scope == models.ErrorScopeTask {
+ return true
+ }
+ switch lastError.Code {
+ case models.LaunchErrorCategoryBaseBranchMissing,
+ models.LaunchErrorCategoryDefaultBranchUnresolved,
+ models.LaunchErrorCategoryWorkspaceCheckoutFailed,
+ models.LaunchErrorCategoryPRAlreadyClosed:
+ return true
+ case models.LaunchErrorCategoryGenericLaunchFailure:
+ var recoveryErr *worktree.WorktreeRecoveryError
+ return errors.As(launchErr, &recoveryErr)
+ default:
+ return false
+ }
+}
+
+// handleLaunchFailed projects shared workspace failures at task scope while
+// retaining profile-specific startup failures on their originating session.
+// The executor has already persisted the source session marker when this
+// callback runs.
+func (s *Service) handleLaunchFailed(
+ ctx context.Context,
+ taskID, sessionID, _ string,
+ launchErr error,
+) {
+ session, err := s.repo.GetTaskSession(ctx, sessionID)
+ if err != nil || session == nil {
+ if err != nil {
+ s.logger.Warn("failed to load launch failure session",
+ zap.String("task_id", taskID),
+ zap.String("session_id", sessionID),
+ zap.Error(err))
+ }
+ return
+ }
+ lastError, found := models.LoadLastAgentError(session.Metadata)
+ if !found || lastError.IsDismissed() {
+ return
+ }
+ if !launchFailureIsTaskOwned(lastError, launchErr) {
+ if err := s.persistBootstrapFailureMessage(ctx, taskID, sessionID, session.AgentExecutionID, lastError); err != nil {
+ s.logger.Warn("failed to persist session-owned launch failure history",
+ zap.String("task_id", taskID),
+ zap.String("session_id", sessionID),
+ zap.Error(err))
+ }
+ return
+ }
+ taskError := models.TaskLaunchError{
+ Message: lastError.Message,
+ OccurredAt: lastError.OccurredAt,
+ Scope: models.ErrorScopeTask,
+ SessionID: sessionID,
+ Code: lastError.Code,
+ Details: lastError.Details,
+ RecoveryActions: lastError.RecoveryActions,
+ TaskRepositoryID: lastError.TaskRepositoryID,
+ StampValue: lastError.Stamp(),
+ }
+ if !s.persistTaskLaunchError(ctx, taskID, taskError) {
+ s.logger.Warn("failed to persist task-owned launch error",
+ zap.String("task_id", taskID),
+ zap.String("session_id", sessionID),
+ zap.Error(launchErr))
+ return
+ }
+ s.dismissRecoveredAgentError(ctx, taskID, session, time.Now().UTC())
+}
+
+// clearRecoveredAgentError retires the current session error while preserving
+// its record for transcript hydration and investigation. The inactive event
+// clears only the live projection; a later failure publishes a new active
+// stamp and re-arms the controls.
func (s *Service) clearRecoveredAgentError(ctx context.Context, taskID string, session *models.TaskSession) {
+ s.dismissRecoveredAgentError(ctx, taskID, session, time.Now().UTC())
+}
+
+func (s *Service) dismissRecoveredAgentError(
+ ctx context.Context,
+ taskID string,
+ session *models.TaskSession,
+ dismissedAt time.Time,
+) {
if session == nil || session.ID == "" {
return
}
- if _, ok := models.LoadLastAgentError(session.Metadata); !ok {
+ lastErr, ok := models.LoadLastAgentError(session.Metadata)
+ if !ok || lastErr.IsDismissed() {
return
}
- if err := s.repo.SetSessionMetadataKey(
- ctx, session.ID, models.SessionMetaKeyLastAgentError, nil,
- ); err != nil {
- s.logger.Warn("failed to clear recovered agent error",
+ lastErr.DismissedAt = &dismissedAt
+ recoveryRepo := s.taskLaunchRecoveryRepo
+ if recoveryRepo == nil {
+ var ok bool
+ recoveryRepo, ok = s.repo.(taskLaunchRecoveryRepository)
+ if !ok {
+ s.logger.Warn("cannot retire recovered agent error without stamp-CAS repository",
+ zap.String("task_id", taskID),
+ zap.String("session_id", session.ID))
+ return
+ }
+ }
+ stored, err := recoveryRepo.SetSessionMetadataKeyIfStamp(
+ ctx,
+ session.ID,
+ models.SessionMetaKeyLastAgentError,
+ lastErr.Stamp(),
+ lastErr,
+ )
+ if err != nil {
+ s.logger.Warn("failed to retire recovered agent error",
zap.String("task_id", taskID),
zap.String("session_id", session.ID),
zap.Error(err))
return
}
+ if !stored {
+ // A successor failure won the metadata race. Do not overwrite the
+ // caller's snapshot or publish an inactive event for the old stamp.
+ return
+ }
// Keep the in-memory copy in step: the session-state publish below reads
// its `session_metadata` straight off this object.
- delete(session.Metadata, models.SessionMetaKeyLastAgentError)
+ if session.Metadata == nil {
+ session.Metadata = make(map[string]interface{})
+ }
+ session.Metadata[models.SessionMetaKeyLastAgentError] = lastErr
if s.eventBus == nil {
return
}
@@ -2910,6 +3027,8 @@ func (s *Service) clearRecoveredAgentError(ctx context.Context, taskID string, s
map[string]interface{}{
"task_id": taskID,
"session_id": session.ID,
+ "scope": models.ErrorScopeSession,
+ "stamp": lastErr.Stamp(),
"active": false,
},
)); err != nil {
@@ -2942,6 +3061,9 @@ func (s *Service) markRecoveryResolved(ctx context.Context, sessionID string, se
session.Metadata = make(map[string]interface{})
}
session.Metadata[models.SessionMetaKeyRecoveryResolvedAt] = resolvedAtValue
+ if session.TaskID != "" {
+ s.dismissRecoveredAgentError(ctx, session.TaskID, session, resolvedAt)
+ }
return &resolvedAt
}
@@ -2956,13 +3078,16 @@ func providerRemediationURL(data watcher.AgentEventData) string {
return data.ProviderError.RemediationURL
}
-// createRecoveryStatusMessage builds and persists the ActionMessage shown
-// in the kanban chat surface after a recoverable agent failure. Must only
-// be called for non-office sessions (office sessions render their own error UI).
-func (s *Service) createRecoveryStatusMessage(ctx context.Context, data watcher.AgentEventData) {
+// createRecoveryStatusMessage builds and persists the ActionMessage shown in
+// the session transcript after a recoverable agent failure. Its stable message
+// identity keeps retries and bootstrap failures idempotent.
+func (s *Service) createRecoveryStatusMessage(ctx context.Context, data watcher.AgentEventData) error {
+ if s.messageCreator == nil {
+ return fmt.Errorf("recovery status message creator is unavailable")
+ }
authErr := isAuthError(data.ErrorMessage)
resumeCorrupted := routingerr.IsResumeCorrupted(data.ErrorMessage)
- displayMsg := data.ErrorMessage
+ displayMsg := agentFailureMessage(data)
if authErr {
if readable := extractReadableAuthError(data.ErrorMessage); readable != "" {
displayMsg = readable
@@ -2974,6 +3099,9 @@ func (s *Service) createRecoveryStatusMessage(ctx context.Context, data watcher.
// session instead of dumping the raw 400.
classified := classifyKanbanFailure(data)
statusMsg := fmt.Sprintf("Agent encountered an error: %s", displayMsg)
+ if data.Phase == models.LaunchErrorPhaseBootstrap {
+ statusMsg = fmt.Sprintf("Agent startup failed: %s", displayMsg)
+ }
if resumeCorrupted {
statusMsg = "This agent session can't be resumed — its saved reasoning state is corrupted. Start a fresh session to continue."
} else if routingerr.Decide(routingerr.ContextKanban, classified, time.Now().UTC()) == routingerr.DecisionShortRetry {
@@ -2985,6 +3113,8 @@ func (s *Service) createRecoveryStatusMessage(ctx context.Context, data watcher.
meta := map[string]interface{}{
"variant": "error",
"recovery_actions": true,
+ "scope": models.ErrorScopeSession,
+ "error_stamp": agentFailureStamp(data),
"session_id": data.SessionID,
"task_id": data.TaskID,
"has_resume_token": hasResumeToken,
@@ -2998,6 +3128,11 @@ func (s *Service) createRecoveryStatusMessage(ctx context.Context, data watcher.
meta["error_output"] = details
}
}
+ if data.Phase == models.LaunchErrorPhaseBootstrap {
+ if details := routingerr.Sanitize(data.FailureDetails); details != "" {
+ meta["error_output"] = details
+ }
+ }
// The validated remediation URL is carried independently of quota
// classification so the generic recoverable card can still show the link.
if remediationURL := providerRemediationURL(data); remediationURL != "" {
@@ -3028,8 +3163,22 @@ func (s *Service) createRecoveryStatusMessage(ctx context.Context, data watcher.
meta["actions"] = buildRecoveryActions(data.TaskID, data.SessionID, hasResumeToken, authErr, resumeCorrupted)
}
- if err := s.messageCreator.CreateSessionMessage(
+ return s.persistRecoveryStatusMessage(ctx, data, statusMsg, meta)
+}
+
+func (s *Service) persistRecoveryStatusMessage(
+ ctx context.Context,
+ data watcher.AgentEventData,
+ statusMsg string,
+ meta map[string]interface{},
+) error {
+ messageID := uuid.NewSHA1(
+ uuid.NameSpaceOID,
+ []byte("session-recovery:"+data.SessionID+":"+agentFailureStamp(data)),
+ )
+ if err := s.messageCreator.CreateSessionMessageIdempotent(
ctx,
+ messageID.String(),
data.TaskID,
statusMsg,
data.SessionID,
@@ -3041,7 +3190,9 @@ func (s *Service) createRecoveryStatusMessage(ctx context.Context, data watcher.
s.logger.Warn("failed to create recovery status message",
zap.String("task_id", data.TaskID),
zap.Error(err))
+ return err
}
+ return nil
}
// applyProviderQuotaMetadata promotes only a validated OpenCode terminal
diff --git a/apps/backend/internal/orchestrator/event_handlers_agent_error_test.go b/apps/backend/internal/orchestrator/event_handlers_agent_error_test.go
index 8000e3afb1..b42bf1a2de 100644
--- a/apps/backend/internal/orchestrator/event_handlers_agent_error_test.go
+++ b/apps/backend/internal/orchestrator/event_handlers_agent_error_test.go
@@ -6,6 +6,7 @@ import (
"testing"
"time"
+ "github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
@@ -408,6 +409,45 @@ func TestDispatchKanbanAgentErrorTrigger_OfficeAndLoadFailureGuards(t *testing.T
})
}
+func TestHandleRecoverableFailurePersistsOfficeSessionHistory(t *testing.T) {
+ ctx := context.Background()
+ repo := setupTestRepo(t)
+ now := time.Now().UTC()
+ requireNoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now}))
+ requireNoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "WF", CreatedAt: now, UpdatedAt: now}))
+ requireNoError(t, repo.CreateTask(ctx, &models.Task{
+ ID: "office-history-task", WorkspaceID: "ws1", WorkflowID: "wf1", WorkflowStepID: "step1",
+ ProjectID: "proj1", Title: "Office task", State: v1.TaskStateInProgress, CreatedAt: now, UpdatedAt: now,
+ }))
+ requireNoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{
+ ID: "office-history-session", TaskID: "office-history-task", State: models.TaskSessionStateRunning,
+ StartedAt: now, UpdatedAt: now,
+ }))
+
+ svc, _ := newAgentErrorTestService(t, repo, newMockStepGetter(), nil)
+ messages := &mockMessageCreator{}
+ svc.messageCreator = messages
+ svc.handleRecoverableFailureLocked(ctx, watcher.AgentEventData{
+ TaskID: "office-history-task",
+ SessionID: "office-history-session",
+ AgentExecutionID: "office-history-execution",
+ ErrorMessage: "The Office agent could not start.",
+ FailureCode: models.LaunchErrorCategoryGenericLaunchFailure,
+ Phase: models.LaunchErrorPhaseBootstrap,
+ AttemptID: "office-history-attempt",
+ ErrorStamp: "office-history-failure",
+ })
+
+ require.Len(t, messages.sessionMessages, 1, "Office failures must have one chronological session entry")
+ require.Equal(t, models.ErrorScopeSession, messages.sessionMessages[0].metadata["scope"])
+ require.Equal(t, "office-history-failure", messages.sessionMessages[0].metadata["error_stamp"])
+ require.Equal(t, true, messages.sessionMessages[0].metadata["recovery_actions"])
+
+ session, err := repo.GetTaskSession(ctx, "office-history-session")
+ requireNoError(t, err)
+ require.Equal(t, models.TaskSessionStateFailed, session.State)
+}
+
type agentErrorTaskLoadErrorRepo struct {
sessionExecutorStore
err error
diff --git a/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go b/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go
index 5e646b7755..aa89af7a57 100644
--- a/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go
+++ b/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go
@@ -860,6 +860,14 @@ func TestHandleAgentBootReady_DoesNotTriggerOnTurnComplete(t *testing.T) {
}); err != nil {
t.Fatalf("create session: %v", err)
}
+ if err := repo.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyLastAgentError, models.LastAgentError{
+ Message: "saved session failed before recovery",
+ OccurredAt: now,
+ Scope: models.ErrorScopeSession,
+ StampValue: "saved-session-failure",
+ }); err != nil {
+ t.Fatalf("set saved session failure: %v", err)
+ }
taskRepo := newMockTaskRepo()
taskRepo.tasks["task-1"] = &v1.Task{
@@ -915,6 +923,10 @@ func TestHandleAgentBootReady_DoesNotTriggerOnTurnComplete(t *testing.T) {
if _, err := time.Parse(time.RFC3339Nano, resolvedAt); err != nil {
t.Fatalf("recovery resolution timestamp %q is invalid: %v", resolvedAt, err)
}
+ lastError, ok := models.LoadLastAgentError(finalSess.Metadata)
+ if !ok || !lastError.IsDismissed() {
+ t.Fatalf("last agent error = %#v, want dismissed historical error", lastError)
+ }
})
}
}
diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go
index 58eb4a4f79..6964b7e186 100644
--- a/apps/backend/internal/orchestrator/event_handlers_streaming.go
+++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go
@@ -1373,7 +1373,9 @@ func (s *Service) transitionBootstrapFailure(
committer, ok := s.repo.(bootstrapFailureCommitter)
if !ok {
- return s.transitionTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateFailed, errorValue.Message, nil)
+ return false, expectedState, fmt.Errorf(
+ "bootstrap failure requires an execution-fenced repository commit",
+ )
}
changed, updatedAt, err := committer.CommitBootstrapFailureIfCurrentExecution(
ctx,
@@ -1389,11 +1391,12 @@ func (s *Service) transitionBootstrapFailure(
}
refreshed, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
- return false, models.TaskSessionStateFailed, fmt.Errorf("get session after bootstrap failure commit: %w", err)
+ return true, models.TaskSessionStateFailed, fmt.Errorf("get session after bootstrap failure commit: %w", err)
}
if refreshed == nil {
- return false, models.TaskSessionStateFailed, fmt.Errorf("get session after bootstrap failure commit: session %q is nil", sessionID)
+ return true, models.TaskSessionStateFailed, fmt.Errorf("get session after bootstrap failure commit: session %q is nil", sessionID)
}
+ messageErr := s.persistBootstrapFailureMessage(ctx, taskID, sessionID, agentExecutionID, errorValue)
authoritativeUpdatedAt := updatedAt.UTC()
s.publishAcceptedTaskSessionState(
ctx,
@@ -1405,9 +1408,37 @@ func (s *Service) transitionBootstrapFailure(
&authoritativeUpdatedAt,
refreshed,
)
+ if messageErr != nil {
+ return true, models.TaskSessionStateFailed, fmt.Errorf("persist bootstrap failure history: %w", messageErr)
+ }
return true, models.TaskSessionStateFailed, nil
}
+// persistBootstrapFailureMessage records the accepted bootstrap failure as a
+// chronological session entry. Its stable message identity makes a retry
+// after the state commit idempotent.
+func (s *Service) persistBootstrapFailureMessage(
+ ctx context.Context,
+ taskID, sessionID, agentExecutionID string,
+ errorValue models.LastAgentError,
+) error {
+ if s.messageCreator == nil {
+ return fmt.Errorf("bootstrap failure message creator is unavailable")
+ }
+ return s.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
+ TaskID: taskID,
+ SessionID: sessionID,
+ AgentExecutionID: agentExecutionID,
+ ErrorMessage: errorValue.Message,
+ FailureCode: errorValue.Code,
+ FailureDetails: errorValue.Details,
+ Phase: errorValue.Phase,
+ AttemptID: errorValue.AttemptID,
+ ErrorStamp: errorValue.Stamp(),
+ Causes: errorValue.Causes,
+ })
+}
+
func (s *Service) publishAcceptedTaskSessionState(
ctx context.Context,
taskID, sessionID string,
diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go
index 0b345d9a43..f32ff652f7 100644
--- a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go
+++ b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go
@@ -272,6 +272,25 @@ func (m *serviceBackedMessageCreator) CreateSessionMessage(
return err
}
+func (m *serviceBackedMessageCreator) CreateSessionMessageIdempotent(
+ ctx context.Context,
+ messageID, taskID, content, sessionID, messageType, turnID string,
+ metadata map[string]interface{},
+ requestsInput bool,
+) error {
+ _, err := m.svc.CreateMessageIdempotent(ctx, messageID, &taskservice.CreateMessageRequest{
+ TaskSessionID: sessionID,
+ TaskID: taskID,
+ TurnID: turnID,
+ Content: content,
+ AuthorType: "agent",
+ Type: messageType,
+ Metadata: metadata,
+ RequestsInput: requestsInput,
+ })
+ return err
+}
+
func (m *serviceBackedMessageCreator) UpdateToolCallMessage(
ctx context.Context,
taskID, toolCallID, parentToolCallID, status, result, agentSessionID, title, turnID, msgType string,
@@ -2819,9 +2838,8 @@ func TestAutoStartFailureDoesNotMarkTaskWithActiveSession(t *testing.T) {
require.False(t, marked, "a late failure must not mark a task with active work")
}
-// Nothing else ever retires a stored agent failure, so without this clear every
-// path that re-derives task status from session metadata resurrects it and the
-// error icon never goes away.
+// A recovered failure remains available for transcript history, while this
+// transition retires it from the live session-error projection.
func TestClearRecoveredAgentErrorOnTurnCompletion(t *testing.T) {
ctx := context.Background()
@@ -2846,11 +2864,12 @@ func TestClearRecoveredAgentErrorOnTurnCompletion(t *testing.T) {
newService := func(repo *sqliterepo.Repository) (*Service, *recordingEventBus) {
eb := &recordingEventBus{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ svc.taskLaunchRecoveryRepo = repo
svc.eventBus = eb
return svc, eb
}
- t.Run("clears the record and publishes it inactive", func(t *testing.T) {
+ t.Run("retires the record and publishes it inactive", func(t *testing.T) {
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
seedError(t, repo)
@@ -2862,13 +2881,15 @@ func TestClearRecoveredAgentErrorOnTurnCompletion(t *testing.T) {
stored, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
- _, ok := models.LoadLastAgentError(stored.Metadata)
- require.False(t, ok, "a recovered failure must not keep driving the error icon")
+ storedError, ok := models.LoadLastAgentError(stored.Metadata)
+ require.True(t, ok, "a recovered failure remains available as history")
+ require.True(t, storedError.IsDismissed(), "a recovered failure must not remain active")
// The session-state publish that follows reads session_metadata straight
// off this object, so a stale copy would re-arm the icon immediately.
- _, ok = models.LoadLastAgentError(session.Metadata)
- require.False(t, ok, "the in-memory copy must be cleared too")
+ memoryError, ok := models.LoadLastAgentError(session.Metadata)
+ require.True(t, ok)
+ require.True(t, memoryError.IsDismissed(), "the in-memory copy must be retired too")
published := errorEvents(eb)
require.Len(t, published, 1, "clients need one event to drop the icon")
@@ -2876,6 +2897,7 @@ func TestClearRecoveredAgentErrorOnTurnCompletion(t *testing.T) {
require.True(t, ok)
require.Equal(t, false, data["active"])
require.Equal(t, "s1", data["session_id"])
+ require.Equal(t, models.ErrorScopeSession, data["scope"])
})
t.Run("stays silent when the session has no stored error", func(t *testing.T) {
@@ -2890,6 +2912,206 @@ func TestClearRecoveredAgentErrorOnTurnCompletion(t *testing.T) {
require.Empty(t, errorEvents(eb),
"every later completion must stay silent so turns do not churn the row")
})
+
+ t.Run("does not retire a successor failure from a stale snapshot", func(t *testing.T) {
+ repo := setupTestRepo(t)
+ seedSession(t, repo, "t1", "s1", "step1")
+ require.NoError(t, repo.SetSessionMetadataKey(
+ ctx, "s1", models.SessionMetaKeyLastAgentError,
+ models.LastAgentError{
+ Message: "old failure",
+ OccurredAt: time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC),
+ StampValue: "old-stamp",
+ },
+ ))
+ svc, eb := newService(repo)
+
+ snapshot, err := repo.GetTaskSession(ctx, "s1")
+ require.NoError(t, err)
+ require.NoError(t, repo.SetSessionMetadataKey(
+ ctx, "s1", models.SessionMetaKeyLastAgentError,
+ models.LastAgentError{
+ Message: "successor failure",
+ OccurredAt: time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC),
+ StampValue: "successor-stamp",
+ },
+ ))
+
+ require.NotNil(t, svc.markRecoveryResolved(ctx, "s1", snapshot))
+
+ stored, err := repo.GetTaskSession(ctx, "s1")
+ require.NoError(t, err)
+ storedError, ok := models.LoadLastAgentError(stored.Metadata)
+ require.True(t, ok)
+ require.Equal(t, "successor-stamp", storedError.Stamp())
+ require.False(t, storedError.IsDismissed())
+ snapshotError, ok := models.LoadLastAgentError(snapshot.Metadata)
+ require.True(t, ok)
+ require.Equal(t, "old-stamp", snapshotError.Stamp())
+ require.False(t, snapshotError.IsDismissed())
+ require.Empty(t, errorEvents(eb), "a stale retirement must not hide the successor")
+ })
+}
+
+func TestTransitionBootstrapFailurePersistsSessionHistory(t *testing.T) {
+ ctx := context.Background()
+ repo := setupTestRepo(t)
+ seedSession(t, repo, "bootstrap-history-task", "bootstrap-history-session", "step1")
+ session, err := repo.GetTaskSession(ctx, "bootstrap-history-session")
+ require.NoError(t, err)
+ session.State = models.TaskSessionStateStarting
+ require.NoError(t, repo.UpdateTaskSession(ctx, session))
+ seedExecutorRunning(t, repo, "bootstrap-history-session", "bootstrap-history-task", "exec-1")
+
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ svc.taskLaunchRecoveryRepo = repo
+ svc.messageCreator = newServiceBackedMessageCreator(repo)
+ startupFailure := &lifecycle.BootstrapFailure{
+ Operation: models.AgentErrorCauseOperationResume,
+ Code: models.AgentErrorCauseCodePermissionDenied,
+ Detail: "The saved session could not be loaded.",
+ Cause: errors.New("provider timeout with private token=secret"),
+ }
+ // The ordinary non-auth startup path returns false so the executor owns the
+ // terminal admission. The transition below is that accepted boundary.
+ require.False(t, svc.handleAgentStartFailed(
+ ctx,
+ "bootstrap-history-task",
+ "bootstrap-history-session",
+ "exec-1",
+ startupFailure,
+ false,
+ ))
+ failure := models.LastAgentError{
+ Message: "The agent could not start.",
+ OccurredAt: time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC),
+ Scope: models.ErrorScopeSession,
+ AgentExecutionID: "exec-1",
+ ExecutionID: "exec-1",
+ Phase: models.LaunchErrorPhaseBootstrap,
+ AttemptID: "attempt-1",
+ Code: models.LaunchErrorCategoryGenericLaunchFailure,
+ Details: "operation=agent_bootstrap; cause=the provider did not respond",
+ StampValue: "bootstrap-failure-1",
+ }
+
+ changed, state, err := svc.transitionBootstrapFailure(
+ ctx,
+ "bootstrap-history-task",
+ "bootstrap-history-session",
+ "exec-1",
+ models.TaskSessionStateStarting,
+ "",
+ failure,
+ )
+ require.NoError(t, err)
+ require.True(t, changed)
+ require.Equal(t, models.TaskSessionStateFailed, state)
+
+ messages, err := repo.ListMessages(ctx, "bootstrap-history-session")
+ require.NoError(t, err)
+ require.Len(t, messages, 1, "accepted bootstrap failure must create one transcript entry")
+ require.Equal(t, models.MessageTypeStatus, messages[0].Type)
+ require.Equal(t, "bootstrap-failure-1", messages[0].Metadata["error_stamp"])
+ require.Equal(t, true, messages[0].Metadata["recovery_actions"])
+ require.Equal(t, failure.Details, messages[0].Metadata["error_output"])
+ require.Equal(t, "Agent startup failed: The agent could not start.", messages[0].Content)
+
+ reloaded, err := repo.GetTaskSession(ctx, "bootstrap-history-session")
+ require.NoError(t, err)
+ resolvedAt := svc.markRecoveryResolved(ctx, reloaded.ID, reloaded)
+ require.NotNil(t, resolvedAt)
+
+ afterRecovery, err := repo.GetTaskSession(ctx, "bootstrap-history-session")
+ require.NoError(t, err)
+ retainedError, ok := models.LoadLastAgentError(afterRecovery.Metadata)
+ require.True(t, ok)
+ require.True(t, retainedError.IsDismissed())
+ retainedMessages, err := repo.ListMessages(ctx, "bootstrap-history-session")
+ require.NoError(t, err)
+ require.Len(t, retainedMessages, 1, "recovery retirement must retain the chronological entry")
+}
+
+type failOnceBootstrapMessageCreator struct {
+ *serviceBackedMessageCreator
+ err error
+}
+
+func (m *failOnceBootstrapMessageCreator) CreateSessionMessageIdempotent(
+ ctx context.Context,
+ messageID, taskID, content, sessionID, messageType, turnID string,
+ metadata map[string]interface{}, requestsInput bool,
+) error {
+ if m.err != nil {
+ err := m.err
+ m.err = nil
+ return err
+ }
+ return m.serviceBackedMessageCreator.CreateSessionMessageIdempotent(
+ ctx,
+ messageID,
+ taskID,
+ content,
+ sessionID,
+ messageType,
+ turnID,
+ metadata,
+ requestsInput,
+ )
+}
+
+func TestTransitionBootstrapFailureReturnsRepairableHistoryError(t *testing.T) {
+ ctx := context.Background()
+ repo := setupTestRepo(t)
+ seedSession(t, repo, "bootstrap-repair-task", "bootstrap-repair-session", "step1")
+ session, err := repo.GetTaskSession(ctx, "bootstrap-repair-session")
+ require.NoError(t, err)
+ session.State = models.TaskSessionStateStarting
+ require.NoError(t, repo.UpdateTaskSession(ctx, session))
+ seedExecutorRunning(t, repo, "bootstrap-repair-session", "bootstrap-repair-task", "exec-repair")
+
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ creator := &failOnceBootstrapMessageCreator{
+ serviceBackedMessageCreator: newServiceBackedMessageCreator(repo),
+ err: errors.New("transcript temporarily unavailable"),
+ }
+ svc.messageCreator = creator
+ failure := models.LastAgentError{
+ Message: "The agent could not start.",
+ OccurredAt: time.Date(2026, 9, 14, 11, 0, 0, 0, time.UTC),
+ Scope: models.ErrorScopeSession,
+ AgentExecutionID: "exec-repair",
+ ExecutionID: "exec-repair",
+ Phase: models.LaunchErrorPhaseBootstrap,
+ AttemptID: "exec-repair",
+ Code: models.LaunchErrorCategoryGenericLaunchFailure,
+ Details: "operation=agent_bootstrap; cause=provider unavailable",
+ StampValue: "bootstrap-repair-failure",
+ }
+
+ changed, state, err := svc.transitionBootstrapFailure(
+ ctx,
+ "bootstrap-repair-task",
+ "bootstrap-repair-session",
+ "exec-repair",
+ models.TaskSessionStateStarting,
+ "",
+ failure,
+ )
+ require.Error(t, err)
+ require.True(t, changed, "the state admission succeeded even though history needed repair")
+ require.Equal(t, models.TaskSessionStateFailed, state)
+ messages, err := repo.ListMessages(ctx, "bootstrap-repair-session")
+ require.NoError(t, err)
+ require.Empty(t, messages)
+
+ require.NoError(t, svc.persistBootstrapFailureMessage(
+ ctx, "bootstrap-repair-task", "bootstrap-repair-session", "exec-repair", failure,
+ ))
+ messages, err = repo.ListMessages(ctx, "bootstrap-repair-session")
+ require.NoError(t, err)
+ require.Len(t, messages, 1, "the bounded repair must restore the accepted failure entry")
+ require.Equal(t, "bootstrap-repair-failure", messages[0].Metadata["error_stamp"])
}
func TestSetSessionStartingRejectsTerminalSession(t *testing.T) {
diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go
index 497fa16437..0e1e2a33cf 100644
--- a/apps/backend/internal/orchestrator/executor/executor.go
+++ b/apps/backend/internal/orchestrator/executor/executor.go
@@ -111,23 +111,6 @@ type sessionMetadataKeyStateSetter interface {
) (bool, error)
}
-// sessionMetadataErrorCASWriter is the optional persistence seam for
-// execution-scoped bootstrap errors. Production repositories implement both
-// operations atomically; legacy test stores can fall back to the ordinary
-// metadata setter after the executor's current-execution fence.
-type sessionMetadataErrorCASWriter interface {
- SetSessionMetadataKeyIfAbsent(
- ctx context.Context,
- sessionID, key string,
- value interface{},
- ) (bool, error)
- SetSessionMetadataKeyIfStamp(
- ctx context.Context,
- sessionID, key, expectedStamp string,
- value interface{},
- ) (bool, error)
-}
-
// bootstrapFailureCommitter is the atomic repository boundary for an
// asynchronous process-start failure. The expected state and error stamp are
// captured immediately before the commit; the repository must also require
@@ -831,6 +814,16 @@ type BootstrapFailureTransitionFunc func(
errorValue models.LastAgentError,
) (changed bool, finalState models.TaskSessionState, err error)
+// BootstrapFailureMessageRepairFunc retries the idempotent transcript write
+// after the state admission has already succeeded. The repair is deliberately
+// separate from the state commit so a transient message-store failure cannot
+// make an accepted bootstrap failure disappear from session history.
+type BootstrapFailureMessageRepairFunc func(
+ ctx context.Context,
+ taskID, sessionID, agentExecutionID string,
+ errorValue models.LastAgentError,
+) error
+
// SessionStartingFunc is called when the executor has prepared/resumed an
// execution and needs to mark the session STARTING while preserving other
// session-row updates such as metadata. expectedState is the state observed
@@ -983,6 +976,9 @@ type Executor struct {
// typed error, FAILED state, and corresponding publication as one ownership
// decision.
onBootstrapFailureTransition BootstrapFailureTransitionFunc
+ // Retry hook for the chronological transcript entry when the state commit
+ // succeeds but the first idempotent message write fails.
+ onBootstrapFailureMessageRepair BootstrapFailureMessageRepairFunc
// Callback for STARTING writes that carry full session-row changes. Set by
// the orchestrator so launch/resume/model-switch transitions serialize with
@@ -1304,6 +1300,12 @@ func (e *Executor) SetOnBootstrapFailureTransition(fn BootstrapFailureTransition
e.onBootstrapFailureTransition = fn
}
+// SetOnBootstrapFailureMessageRepair wires the bounded repair hook for a
+// bootstrap failure whose session admission already succeeded.
+func (e *Executor) SetOnBootstrapFailureMessageRepair(fn BootstrapFailureMessageRepairFunc) {
+ e.onBootstrapFailureMessageRepair = fn
+}
+
// SetOnSessionStarting sets a callback for full session-row STARTING updates.
func (e *Executor) SetOnSessionStarting(fn SessionStartingFunc) {
e.onSessionStarting = fn
diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go
index d0f99cdff2..446b92423a 100644
--- a/apps/backend/internal/orchestrator/executor/executor_execute.go
+++ b/apps/backend/internal/orchestrator/executor/executor_execute.go
@@ -285,6 +285,17 @@ func (e *Executor) handleAgentProcessStartFailure(
zap.String("session_id", sessionID),
zap.String("agent_execution_id", agentExecutionID),
zap.Error(transitionErr))
+ if changed && finalState == models.TaskSessionStateFailed && e.onBootstrapFailureMessageRepair != nil {
+ if repairErr := e.onBootstrapFailureMessageRepair(
+ ctx, taskID, sessionID, agentExecutionID, errorValue,
+ ); repairErr != nil {
+ e.logger.Warn("failed to repair bootstrap failure history",
+ zap.String("task_id", taskID),
+ zap.String("session_id", sessionID),
+ zap.String("agent_execution_id", agentExecutionID),
+ zap.Error(repairErr))
+ }
+ }
} else if !changed {
// An ownership or compare-and-set miss means a newer execution won the
// race. Do not
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
index d45a0b5fae..85c3465174 100644
--- a/apps/backend/internal/orchestrator/executor/executor_launch_failure_classification_test.go
+++ b/apps/backend/internal/orchestrator/executor/executor_launch_failure_classification_test.go
@@ -170,6 +170,9 @@ func TestBuildLastAgentErrorSanitizesRepositoryPreparationDetails(t *testing.T)
if !strings.Contains(errorValue.Details, "repo-back") || !strings.Contains(errorValue.Details, "backend") {
t.Fatalf("launch details = %q, want repository identity", errorValue.Details)
}
+ if errorValue.Phase != models.LaunchErrorPhaseBootstrap {
+ t.Fatalf("launch phase = %q, want bootstrap", errorValue.Phase)
+ }
if strings.Contains(errorValue.Details, "ghp_abcdefghijklmnopqrstuvwxyz1234567890AB") ||
strings.Contains(errorValue.Details, "user:") {
t.Fatalf("launch details exposed credential-bearing URL: %q", errorValue.Details)
@@ -235,6 +238,27 @@ func TestBootstrapFailureProjection(t *testing.T) {
exec.SetOnSessionStateChange(func(ctx context.Context, _, sessionID string, state models.TaskSessionState, message string) error {
return repo.UpdateTaskSessionState(ctx, sessionID, state, message)
})
+ exec.SetOnBootstrapFailureTransition(func(
+ ctx context.Context,
+ taskID, sessionID, _ string,
+ _ models.TaskSessionState,
+ _ string,
+ errorValue models.LastAgentError,
+ ) (bool, models.TaskSessionState, error) {
+ changed, _, err := repo.CommitBootstrapFailureIfCurrentExecution(
+ ctx,
+ taskID,
+ sessionID,
+ "exec-1",
+ models.TaskSessionStateStarting,
+ "",
+ errorValue,
+ )
+ if err != nil || !changed {
+ return changed, models.TaskSessionStateStarting, err
+ }
+ return true, models.TaskSessionStateFailed, nil
+ })
exec.runAgentProcessAsync(
context.Background(), "task-1", "session-1", "exec-1",
@@ -273,6 +297,54 @@ func TestBootstrapFailureProjection(t *testing.T) {
}
}
+func TestBootstrapFailureHistoryRepairRunsAfterAcceptedCommitError(t *testing.T) {
+ repo := newMockRepository()
+ repo.sessions["session-repair"] = &models.TaskSession{
+ ID: "session-repair",
+ TaskID: "task-repair",
+ State: models.TaskSessionStateStarting,
+ AgentExecutionID: "exec-repair",
+ }
+ repo.tasks["task-repair"] = &models.Task{ID: "task-repair", State: v1.TaskStateReview}
+ exec := newTestExecutor(t, &mockAgentManager{}, repo)
+ var repairCalls int
+ exec.SetOnBootstrapFailureTransition(func(
+ context.Context,
+ string,
+ string,
+ string,
+ models.TaskSessionState,
+ string,
+ models.LastAgentError,
+ ) (bool, models.TaskSessionState, error) {
+ return true, models.TaskSessionStateFailed, errors.New("transcript write failed after admission")
+ })
+ exec.SetOnBootstrapFailureMessageRepair(func(
+ context.Context,
+ string,
+ string,
+ string,
+ models.LastAgentError,
+ ) error {
+ repairCalls++
+ return nil
+ })
+
+ exec.handleAgentProcessStartFailure(
+ context.Background(),
+ "task-repair",
+ "session-repair",
+ "exec-repair",
+ errors.New("agent process failed to start"),
+ false,
+ false,
+ )
+
+ if repairCalls != 1 {
+ t.Fatalf("bootstrap history repair calls = %d, want 1", repairCalls)
+ }
+}
+
func TestBootstrapFailureSuccessorFence(t *testing.T) {
repo := newMockRepository()
successor := models.LastAgentError{
diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go
index c84d5b5ac4..5c33271380 100644
--- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go
+++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go
@@ -544,6 +544,45 @@ func (m *mockRepository) UpdateTaskSessionStateIfCurrent(
return true, now, nil
}
+// CommitBootstrapFailureIfCurrentExecution mirrors the production admission
+// boundary for executor tests. The fake has no separate executor table, so the
+// session's execution identity is the equivalent ownership fence.
+func (m *mockRepository) CommitBootstrapFailureIfCurrentExecution(
+ _ context.Context,
+ taskID, sessionID, agentExecutionID string,
+ expectedState models.TaskSessionState,
+ expectedStamp string,
+ errorValue models.LastAgentError,
+) (bool, time.Time, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ session, ok := m.sessions[sessionID]
+ if !ok || session == nil || (session.TaskID != "" && session.TaskID != taskID) || session.State != expectedState {
+ return false, time.Time{}, nil
+ }
+ if agentExecutionID != "" && session.AgentExecutionID != "" && session.AgentExecutionID != agentExecutionID {
+ return false, time.Time{}, nil
+ }
+ current, hasCurrent := models.LoadLastAgentError(session.Metadata)
+ if expectedStamp == "" {
+ if hasCurrent {
+ return false, time.Time{}, nil
+ }
+ } else if !hasCurrent || current.Stamp() != expectedStamp {
+ return false, time.Time{}, nil
+ }
+ if session.Metadata == nil {
+ session.Metadata = make(map[string]interface{})
+ }
+ session.Metadata[models.SessionMetaKeyLastAgentError] = errorValue
+ now := time.Now().UTC()
+ session.State = models.TaskSessionStateFailed
+ session.ErrorMessage = errorValue.Message
+ session.CompletedAt = &now
+ session.UpdatedAt = now
+ return true, now, nil
+}
+
func (m *mockRepository) UpdateTaskSessionIfCurrentStateRemovingMetadataKeys(
ctx context.Context,
session *models.TaskSession,
diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go
index f9636158a5..99bec3ddb8 100644
--- a/apps/backend/internal/orchestrator/executor/executor_test.go
+++ b/apps/backend/internal/orchestrator/executor/executor_test.go
@@ -2135,6 +2135,27 @@ func TestRunAgentProcessAsync_CleansUpOnStartFailure(t *testing.T) {
exec.SetOnSessionStateChange(func(ctx context.Context, taskID, sessionID string, state models.TaskSessionState, errorMessage string) error {
return repo.UpdateTaskSessionState(ctx, sessionID, state, errorMessage)
})
+ exec.SetOnBootstrapFailureTransition(func(
+ ctx context.Context,
+ taskID, sessionID, _ string,
+ _ models.TaskSessionState,
+ _ string,
+ errorValue models.LastAgentError,
+ ) (bool, models.TaskSessionState, error) {
+ changed, _, err := repo.CommitBootstrapFailureIfCurrentExecution(
+ ctx,
+ taskID,
+ sessionID,
+ "exec-456",
+ models.TaskSessionStateStarting,
+ "",
+ errorValue,
+ )
+ if err != nil || !changed {
+ return changed, models.TaskSessionStateStarting, err
+ }
+ return true, models.TaskSessionStateFailed, nil
+ })
exec.SetOnTaskStateChange(func(ctx context.Context, taskID string, state v1.TaskState) error {
return repo.UpdateTaskState(ctx, taskID, state)
})
@@ -2310,6 +2331,15 @@ func TestHandleAgentProcessStartFailure_CancellationDuringCallbackStopsUnclaimed
transitionCalls.Add(1)
return false, models.TaskSessionStateCancelled, nil
})
+ exec.SetOnBootstrapFailureTransition(func(
+ ctx context.Context,
+ taskID, sessionID, _ string,
+ _ models.TaskSessionState,
+ _ string,
+ errorValue models.LastAgentError,
+ ) (bool, models.TaskSessionState, error) {
+ return exec.transitionSessionState(ctx, taskID, sessionID, models.TaskSessionStateFailed, errorValue.Message)
+ })
exec.SetOnExecutionCleanupClaim(func(sessionID, executionID string) bool {
if sessionID != "session-123" || executionID != "exec-456" {
t.Fatalf("cleanup claim = (%q, %q), want (session-123, exec-456)", sessionID, executionID)
@@ -2654,6 +2684,30 @@ func newRunAgentProcessAsyncFailureFixture(t *testing.T) *runAgentProcessAsyncFa
}
return repo.UpdateTaskSessionState(ctx, sessionID, state, errorMessage)
})
+ f.exec.SetOnBootstrapFailureTransition(func(
+ ctx context.Context,
+ taskID, sessionID, _ string,
+ _ models.TaskSessionState,
+ _ string,
+ errorValue models.LastAgentError,
+ ) (bool, models.TaskSessionState, error) {
+ changed, _, err := repo.CommitBootstrapFailureIfCurrentExecution(
+ ctx,
+ taskID,
+ sessionID,
+ "exec-456",
+ models.TaskSessionStateStarting,
+ "",
+ errorValue,
+ )
+ if changed {
+ f.sessionFailedSeen = true
+ }
+ if err != nil || !changed {
+ return changed, models.TaskSessionStateStarting, err
+ }
+ return true, models.TaskSessionStateFailed, nil
+ })
f.exec.SetOnAgentStartFailed(func(ctx context.Context, taskID, sessionID, agentExecutionID string, err error, fromResume bool) bool {
f.startFailedCalls++
f.lastFromResume = fromResume
diff --git a/apps/backend/internal/orchestrator/executor/launch_failure.go b/apps/backend/internal/orchestrator/executor/launch_failure.go
index cf4bd5889d..5b06fd21f7 100644
--- a/apps/backend/internal/orchestrator/executor/launch_failure.go
+++ b/apps/backend/internal/orchestrator/executor/launch_failure.go
@@ -102,6 +102,8 @@ func (e *Executor) buildLastAgentError(
return models.LastAgentError{
Message: classification.message,
OccurredAt: occurredAt,
+ Scope: models.ErrorScopeSession,
+ Phase: models.LaunchErrorPhaseBootstrap,
Code: classification.code,
Details: details,
RecoveryActions: func() []string {
@@ -167,6 +169,7 @@ func (e *Executor) buildBootstrapLastAgentError(
return models.LastAgentError{
Message: classification.message,
OccurredAt: occurredAt,
+ Scope: models.ErrorScopeSession,
AgentExecutionID: agentExecutionID,
ExecutionID: agentExecutionID,
Phase: models.LaunchErrorPhaseBootstrap,
@@ -306,8 +309,8 @@ func (e *Executor) bootstrapFailureExpectation(
// commitBootstrapFailure commits the correlated failure projection and the
// FAILED transition through the atomic orchestrator callback or repository
-// capability. Legacy test stores retain the older CAS plus state-transition
-// fallback, but the production SQL repository uses the guarded commit.
+// capability. Stores without an execution-fenced commit fail closed so a
+// bootstrap failure cannot overwrite a successor execution.
func (e *Executor) commitBootstrapFailure(
ctx context.Context,
taskID, sessionID, agentExecutionID string,
@@ -352,43 +355,7 @@ func (e *Executor) commitBootstrapFailure(
return true, models.TaskSessionStateFailed, nil
}
- // Legacy stores cannot atomically fence the executor row and session write.
- // Keep their existing behavior for focused test doubles and older adapters;
- // the production repository always takes the guarded path above.
- writer, supportsCAS := e.repo.(sessionMetadataErrorCASWriter)
- var persisted bool
- if supportsCAS {
- if expectedStamp != "" {
- persisted, err = writer.SetSessionMetadataKeyIfStamp(
- ctx,
- sessionID,
- models.SessionMetaKeyLastAgentError,
- expectedStamp,
- errorValue,
- )
- } else {
- persisted, err = writer.SetSessionMetadataKeyIfAbsent(
- ctx,
- sessionID,
- models.SessionMetaKeyLastAgentError,
- errorValue,
- )
- }
- } else {
- err = e.repo.SetSessionMetadataKey(
- ctx, sessionID, models.SessionMetaKeyLastAgentError, errorValue,
- )
- persisted = err == nil
- }
- if err != nil {
- return false, session.State, err
- }
- if !persisted {
- return false, session.State, nil
- }
-
- changed, finalState, err := e.transitionSessionState(
- ctx, taskID, sessionID, models.TaskSessionStateFailed, errorValue.Message,
+ return false, session.State, fmt.Errorf(
+ "bootstrap failure requires an execution-fenced repository commit",
)
- return changed, finalState, err
}
diff --git a/apps/backend/internal/orchestrator/recovery_actions_test.go b/apps/backend/internal/orchestrator/recovery_actions_test.go
index 6b97937ace..0fc8182522 100644
--- a/apps/backend/internal/orchestrator/recovery_actions_test.go
+++ b/apps/backend/internal/orchestrator/recovery_actions_test.go
@@ -8,6 +8,8 @@ import (
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/orchestrator/watcher"
+ "github.com/kandev/kandev/internal/task/models"
+ "github.com/stretchr/testify/require"
)
// thinkingBlocks400 is the resume-corrupted signature surfaced by the
@@ -77,18 +79,28 @@ func TestCreateRecoveryStatusMessage_ManagedRuntimeNpmUsesOneRetryAction(t *test
mc := &mockMessageCreator{}
svc.messageCreator = mc
- svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
+ require.NoError(t, svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
TaskID: "t-npm",
SessionID: "s-npm",
ErrorMessage: "managed npm runtime failed to prepare",
FailureCode: "managed_runtime_npm_resolution",
FailureDetails: "npm error code ETARGET\nnpm error notarget No matching version found",
- })
+ }))
+ require.NoError(t, svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
+ TaskID: "t-npm",
+ SessionID: "s-npm",
+ ErrorMessage: "managed npm runtime failed to prepare",
+ FailureCode: "managed_runtime_npm_resolution",
+ FailureDetails: "npm error code ETARGET\nnpm error notarget No matching version found",
+ }))
if len(mc.sessionMessages) != 1 {
t.Fatalf("expected one recovery message, got %d", len(mc.sessionMessages))
}
meta := mc.sessionMessages[0].metadata
+ if meta["scope"] != models.ErrorScopeSession || meta["error_stamp"] == "" {
+ t.Fatalf("recovery identity = %#v", meta)
+ }
if meta["failure_kind"] != "managed_runtime_npm_resolution" {
t.Fatalf("failure_kind = %#v", meta["failure_kind"])
}
@@ -120,11 +132,11 @@ func TestCreateRecoveryStatusMessage_ResumeCorrupted(t *testing.T) {
mc := &mockMessageCreator{}
svc.messageCreator = mc
- svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
+ require.NoError(t, svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
TaskID: "t1",
SessionID: "s1",
ErrorMessage: thinkingBlocks400,
- })
+ }))
if len(mc.sessionMessages) != 1 {
t.Fatalf("expected 1 session message, got %d", len(mc.sessionMessages))
@@ -147,12 +159,12 @@ func TestCreateRecoveryStatusMessage_TransientExhaustionUsesSafeReason(t *testin
mc := &mockMessageCreator{}
svc.messageCreator = mc
- svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
+ require.NoError(t, svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
TaskID: "t-capacity",
SessionID: "s-capacity",
AgentID: "codex-acp",
ErrorMessage: "Selected model is at capacity. Please try a different model.",
- })
+ }))
if len(mc.sessionMessages) != 1 {
t.Fatalf("expected 1 session message, got %d", len(mc.sessionMessages))
@@ -177,7 +189,7 @@ func TestCreateRecoveryStatusMessage_OpenCodeQuotaCarriesSafeMetadata(t *testing
resetAt := time.Date(2026, 8, 2, 19, 34, 44, 0, time.UTC)
const wantURL = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go"
- svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
+ require.NoError(t, svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
TaskID: "t-quota",
SessionID: "s-quota",
AgentID: "opencode-acp",
@@ -191,7 +203,7 @@ func TestCreateRecoveryStatusMessage_OpenCodeQuotaCarriesSafeMetadata(t *testing
OccurredAt: time.Date(2026, 8, 2, 15, 15, 44, 0, time.UTC),
ResetAt: &resetAt,
},
- })
+ }))
if len(mc.sessionMessages) != 1 {
t.Fatalf("expected 1 session message, got %d", len(mc.sessionMessages))
@@ -226,7 +238,7 @@ func TestCreateRecoveryStatusMessage_GenericFailureCarriesRemediationURL(t *test
// A structured ACP-sourced diagnostic is not quota-classified, but the
// validated link still reaches the generic recoverable card.
- svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
+ require.NoError(t, svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
TaskID: "t-generic",
SessionID: "s-generic",
AgentID: "opencode-acp",
@@ -237,7 +249,7 @@ func TestCreateRecoveryStatusMessage_GenericFailureCarriesRemediationURL(t *test
RemediationURL: wantURL,
OccurredAt: time.Date(2026, 8, 2, 15, 15, 44, 0, time.UTC),
},
- })
+ }))
if len(mc.sessionMessages) != 1 {
t.Fatalf("expected 1 session message, got %d", len(mc.sessionMessages))
@@ -279,12 +291,12 @@ func TestProviderRemediationURLRejectsInvalidDiagnostics(t *testing.T) {
}},
} {
t.Run(tt.name, func(t *testing.T) {
- svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
+ require.NoError(t, svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
TaskID: "t-none",
SessionID: "s-none",
ErrorMessage: "provider failed",
ProviderError: tt.providerErr,
- })
+ }))
if len(mc.sessionMessages) == 0 {
t.Fatal("expected a session message")
}
@@ -292,6 +304,7 @@ func TestProviderRemediationURLRejectsInvalidDiagnostics(t *testing.T) {
t.Fatalf("remediation_url = %#v, want absent", url)
}
mc.sessionMessages = nil
+ mc.idempotentSessionMessages = nil
})
}
}
diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go
index b810ca2401..c497280ccf 100644
--- a/apps/backend/internal/orchestrator/service.go
+++ b/apps/backend/internal/orchestrator/service.go
@@ -124,6 +124,7 @@ type MessageCreator interface {
// parentToolCallID is the parent Task tool call ID for subagent nesting (empty for top-level).
UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, status, result, agentSessionID, title, turnID, msgType string, normalized *streams.NormalizedPayload) error
CreateSessionMessage(ctx context.Context, taskID, content, agentSessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error
+ CreateSessionMessageIdempotent(ctx context.Context, messageID, taskID, content, agentSessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error
CreatePermissionRequestMessage(ctx context.Context, taskID, sessionID, requestID, pendingID, toolCallID, title, turnID string, options []map[string]interface{}, actionType string, actionDetails map[string]interface{}) (string, error)
UpdatePermissionMessage(ctx context.Context, taskID, sessionID, requestID, pendingID string, status models.PermissionStatus) error
ClaimPermissionResolution(ctx context.Context, request models.PermissionResolutionClaimRequest) (*models.PermissionResolutionClaimResult, error)
@@ -1707,6 +1708,7 @@ func NewService(
})
exec.SetOnSessionStateTransition(s.transitionTaskSessionState)
exec.SetOnBootstrapFailureTransition(s.transitionBootstrapFailure)
+ exec.SetOnBootstrapFailureMessageRepair(s.persistBootstrapFailureMessage)
exec.SetOnSessionStarting(func(
ctx context.Context,
taskID string,
@@ -1728,6 +1730,7 @@ func NewService(
ctx, taskID, session, expectedState, promoteTask, allowCompletedResume,
)
})
+ exec.SetOnLaunchFailed(s.handleLaunchFailed)
exec.SetOnExecutionCleanupClaim(s.claimForcedExecutionCleanup)
exec.SetOnExecutionStopOwnerRegistration(s.RegisterExecutionStopOwner)
exec.SetOnTaskReviewStateReconcile(func(ctx context.Context, taskID, completedSessionID string) {
diff --git a/apps/backend/internal/orchestrator/task_launch_pr_gate.go b/apps/backend/internal/orchestrator/task_launch_pr_gate.go
index 1de3c6302a..0ebea06b2b 100644
--- a/apps/backend/internal/orchestrator/task_launch_pr_gate.go
+++ b/apps/backend/internal/orchestrator/task_launch_pr_gate.go
@@ -196,6 +196,7 @@ func (s *Service) shouldSkipTerminalPRAutoStart(
errorValue := models.TaskLaunchError{
Message: terminalPRLaunchMessage,
OccurredAt: time.Now().UTC(),
+ Scope: models.ErrorScopeTask,
Code: models.LaunchErrorCategoryPRAlreadyClosed,
RecoveryActions: terminalPRRecoveryActions(ctx, s.workflowStepGetter, task.WorkflowStepID),
TaskRepositoryID: taskPRLaunchErrorRepositoryID(matches),
diff --git a/apps/backend/internal/orchestrator/task_launch_recovery.go b/apps/backend/internal/orchestrator/task_launch_recovery.go
index 63a0c4b76f..df69eb7109 100644
--- a/apps/backend/internal/orchestrator/task_launch_recovery.go
+++ b/apps/backend/internal/orchestrator/task_launch_recovery.go
@@ -12,7 +12,6 @@ import (
"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"
@@ -65,7 +64,14 @@ type taskLaunchRecoveryRepository interface {
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 taskLaunchRecoveryEnvironmentReader interface {
+ GetTaskEnvironmentByTaskID(context.Context, string) (*models.TaskEnvironment, error)
+}
+
+type taskLaunchRecoveryEnvironmentResetter interface {
+ ResetTaskEnvironment(context.Context, string, taskservice.ResetOptions) error
}
type taskLaunchRecoverySource struct {
@@ -216,72 +222,35 @@ func (s *Service) verifyCurrentTaskLaunchRecoverySource(
ctx context.Context,
source *taskLaunchRecoverySource,
) error {
- current, err := s.loadAuthoritativeTaskLaunchRecoveryError(ctx, source.task.ID)
+ if source.sessionOwned {
+ currentSession, err := s.repo.GetTaskSession(ctx, source.session.ID)
+ if err != nil {
+ return err
+ }
+ if currentSession == nil {
+ return ErrTaskLaunchRecoveryStale
+ }
+ current, found := models.LoadLastAgentError(currentSession.Metadata)
+ if !found || current.IsDismissed() || !current.MatchesStamp(source.errorStamp) {
+ return ErrTaskLaunchRecoveryStale
+ }
+ return nil
+ }
+
+ currentTask, err := s.repo.GetTask(ctx, source.task.ID)
if err != nil {
return err
}
- if current == nil || current.SessionID != source.sessionID() || current.Stamp != source.errorStamp {
+ if currentTask == nil {
+ return ErrTaskLaunchRecoveryStale
+ }
+ current, found := models.LoadTaskLaunchError(currentTask.Metadata)
+ if !found || !current.MatchesStamp(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,
- ExecutionID: lastError.ExecutionID,
- AttemptID: lastError.AttemptID,
- Phase: lastError.Phase,
- Stamp: lastError.Stamp(),
- OccurredAt: lastError.OccurredAt,
- Preview: lastError.Message,
- Details: lastError.Details,
- Category: lastError.Code,
- RecoveryActions: lastError.RecoveryActions,
- Causes: lastError.Causes,
- },
- })
- }
- if launchError, found := models.LoadTaskLaunchError(task.Metadata); found {
- input.TaskError = &statussummary.ActiveErrorSummary{
- TaskRepositoryID: launchError.TaskRepositoryID,
- Stamp: launchError.Stamp(),
- OccurredAt: launchError.OccurredAt,
- Preview: launchError.Message,
- Details: launchError.Details,
- Category: launchError.Code,
- RecoveryActions: launchError.RecoveryActions,
- }
- }
- return statussummary.BuildFromAuthoritative(input).ActiveError, nil
-}
-
func (s *Service) loadSessionTaskLaunchRecoverySource(
ctx context.Context,
req *TaskLaunchRecoveryRequest,
@@ -298,7 +267,7 @@ func (s *Service) loadSessionTaskLaunchRecoverySource(
return nil, fmt.Errorf("session does not belong to task")
}
lastError, found := models.LoadLastAgentError(session.Metadata)
- if !found {
+ if !found || lastError.IsDismissed() {
return nil, fmt.Errorf("session has no active launch error")
}
if !lastError.MatchesStamp(req.ErrorStamp) {
@@ -354,13 +323,6 @@ func (source *taskLaunchRecoverySource) taskRepositoryID() string {
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
@@ -492,6 +454,7 @@ func (s *Service) recordSessionUnresolvedDefault(
value := models.LastAgentError{
Message: message,
OccurredAt: time.Now().UTC(),
+ Scope: models.ErrorScopeSession,
Code: models.LaunchErrorCategoryDefaultBranchUnresolved,
Details: details,
RecoveryActions: []string{models.RecoveryActionPickBaseBranch},
@@ -530,6 +493,7 @@ func (s *Service) recordTaskUnresolvedDefault(
value := models.TaskLaunchError{
Message: message,
OccurredAt: time.Now().UTC(),
+ Scope: models.ErrorScopeTask,
Code: models.LaunchErrorCategoryDefaultBranchUnresolved,
Details: details,
RecoveryActions: []string{models.RecoveryActionPickBaseBranch},
@@ -564,8 +528,13 @@ func (s *Service) relaunchRecoveredTask(ctx context.Context, req *TaskLaunchReco
}
return response.SessionID, nil
}
+ if err := s.resetFailedTaskEnvironmentForRecovery(ctx, req.TaskID); err != nil {
+ return "", err
+ }
+ agentProfileID, _ := s.resolveTaskAgentProfile(ctx, source.task)
response, err := s.LaunchSession(ctx, &LaunchSessionRequest{
TaskID: req.TaskID,
+ AgentProfileID: agentProfileID,
Intent: IntentStart,
WorkflowStepID: source.task.WorkflowStepID,
})
@@ -578,6 +547,29 @@ func (s *Service) relaunchRecoveredTask(ctx context.Context, req *TaskLaunchReco
return response.SessionID, nil
}
+// resetFailedTaskEnvironmentForRecovery removes the unusable environment left
+// by a failed first materialization. Ready environments remain attached so a
+// task-scoped launch error from a later session does not destroy a usable
+// workspace before retrying.
+func (s *Service) resetFailedTaskEnvironmentForRecovery(ctx context.Context, taskID string) error {
+ reader, canRead := s.taskLaunchRecoveryTasks.(taskLaunchRecoveryEnvironmentReader)
+ resetter, canReset := s.taskLaunchRecoveryTasks.(taskLaunchRecoveryEnvironmentResetter)
+ if !canRead || !canReset {
+ return nil
+ }
+ environment, err := reader.GetTaskEnvironmentByTaskID(ctx, taskID)
+ if err != nil {
+ return fmt.Errorf("load failed task environment before recovery: %w", err)
+ }
+ if environment == nil || environment.Status != models.TaskEnvironmentStatusFailed {
+ return nil
+ }
+ if err := resetter.ResetTaskEnvironment(ctx, taskID, taskservice.ResetOptions{}); err != nil {
+ return fmt.Errorf("reset failed task environment before recovery: %w", err)
+ }
+ return 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")
@@ -634,8 +626,31 @@ func (s *Service) clearTaskLaunchRecoverySource(ctx context.Context, source *tas
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
+ retired := source.sessionError
+ dismissedAt := time.Now().UTC()
+ retired.DismissedAt = &dismissedAt
+ stored, err := s.taskLaunchRecoveryRepo.SetSessionMetadataKeyIfStamp(
+ ctx,
+ source.session.ID,
+ models.SessionMetaKeyLastAgentError,
+ source.errorStamp,
+ retired,
+ )
+ if err != nil {
+ return err
+ }
+ if !stored {
+ return ErrTaskLaunchRecoveryStale
+ }
+ if s.eventBus != nil {
+ if err := s.publishTaskSessionErrorEvent(ctx, source.task.ID, source.session.ID, false, &retired); err != nil {
+ s.logger.Warn("failed to publish retired task session error",
+ zap.String("task_id", source.task.ID),
+ zap.String("session_id", source.session.ID),
+ zap.Error(err))
+ }
+ }
+ return nil
}
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
index 987a81ca94..b25d6a4325 100644
--- a/apps/backend/internal/orchestrator/task_launch_recovery_test.go
+++ b/apps/backend/internal/orchestrator/task_launch_recovery_test.go
@@ -8,6 +8,8 @@ import (
"testing"
"time"
+ "github.com/kandev/kandev/internal/events"
+ "github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/task/models"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
@@ -24,6 +26,21 @@ type taskLaunchRecoveryServiceFake struct {
moved []taskservice.MoveTaskOptions
moveErr error
moveTaskIDs []string
+ environment *models.TaskEnvironment
+ resetErr error
+ resetCalls int
+}
+
+type failingTaskSessionErrorBus struct {
+ bus.EventBus
+ err error
+}
+
+func (b failingTaskSessionErrorBus) Publish(ctx context.Context, subject string, event *bus.Event) error {
+ if subject == events.TaskSessionErrorChanged {
+ return b.err
+ }
+ return b.EventBus.Publish(ctx, subject, event)
}
func (f *taskLaunchRecoveryServiceFake) UpdateRepositoryBaseBranch(_ context.Context, req taskservice.UpdateRepositoryBaseBranchRequest) (*models.TaskRepository, error) {
@@ -44,6 +61,46 @@ func (f *taskLaunchRecoveryServiceFake) MoveTaskWithOptions(_ context.Context, t
return &taskservice.MoveTaskResult{Task: &models.Task{ID: taskID, WorkflowID: workflowID, WorkflowStepID: workflowStepID, Position: position}}, nil
}
+func (f *taskLaunchRecoveryServiceFake) GetTaskEnvironmentByTaskID(context.Context, string) (*models.TaskEnvironment, error) {
+ return f.environment, nil
+}
+
+func (f *taskLaunchRecoveryServiceFake) ResetTaskEnvironment(context.Context, string, taskservice.ResetOptions) error {
+ f.resetCalls++
+ return f.resetErr
+}
+
+func TestResetFailedTaskEnvironmentForRecoveryOnlyResetsFailedEnvironment(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ status models.TaskEnvironmentStatus
+ wantCalls int
+ wantErr bool
+ resetError error
+ }{
+ {name: "no environment", wantCalls: 0},
+ {name: "ready environment", status: models.TaskEnvironmentStatusReady, wantCalls: 0},
+ {name: "failed environment", status: models.TaskEnvironmentStatusFailed, wantCalls: 1},
+ {name: "reset failure", status: models.TaskEnvironmentStatusFailed, wantCalls: 1, wantErr: true, resetError: errors.New("reset failed")},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ fake := &taskLaunchRecoveryServiceFake{resetErr: tc.resetError}
+ if tc.status != "" {
+ fake.environment = &models.TaskEnvironment{ID: "env-1", Status: tc.status}
+ }
+ svc := &Service{taskLaunchRecoveryTasks: fake}
+
+ err := svc.resetFailedTaskEnvironmentForRecovery(context.Background(), "task-1")
+ if (err != nil) != tc.wantErr {
+ t.Fatalf("resetFailedTaskEnvironmentForRecovery error = %v, want error = %t", err, tc.wantErr)
+ }
+ if fake.resetCalls != tc.wantCalls {
+ t.Fatalf("reset calls = %d, want %d", fake.resetCalls, tc.wantCalls)
+ }
+ })
+ }
+}
+
type taskLaunchRecoveryWorktreeFake struct {
branch string
err error
@@ -132,6 +189,166 @@ func recoveryFixtureService(t *testing.T, repo *sqliterepo.Repository, fake *tas
return svc
}
+func TestHandleLaunchFailedProjectsTaskErrorAndRetiresSessionMarker(t *testing.T) {
+ repo := setupTestRepo(t)
+ seedSession(t, repo, "task-launch-failure", "session-launch-failure", "step1")
+ now := time.Now().UTC()
+ lastError := models.LastAgentError{
+ Message: "The selected base branch is not available.",
+ OccurredAt: now,
+ Scope: models.ErrorScopeSession,
+ Code: models.LaunchErrorCategoryBaseBranchMissing,
+ Details: "base branch is missing",
+ RecoveryActions: []string{models.RecoveryActionRetryDefault, models.RecoveryActionPickBaseBranch},
+ TaskRepositoryID: "task-repository-launch-failure",
+ StampValue: "launch-failure-stamp",
+ }
+ if err := repo.SetSessionMetadataKey(context.Background(), "session-launch-failure", models.SessionMetaKeyLastAgentError, lastError); err != nil {
+ t.Fatalf("SetSessionMetadataKey: %v", err)
+ }
+
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ svc.handleLaunchFailed(context.Background(), "task-launch-failure", "session-launch-failure", "repo-1", errors.New("workspace preparation failed"))
+
+ task, err := repo.GetTask(context.Background(), "task-launch-failure")
+ if err != nil {
+ t.Fatalf("GetTask: %v", err)
+ }
+ taskError, ok := models.LoadTaskLaunchError(task.Metadata)
+ if !ok {
+ t.Fatal("task launch error was not persisted")
+ }
+ if taskError.Scope != models.ErrorScopeTask || taskError.SessionID != "session-launch-failure" {
+ t.Fatalf("task launch error ownership = %#v, want task scope with source session", taskError)
+ }
+ if taskError.Stamp() != lastError.Stamp() {
+ t.Fatalf("task launch error stamp = %q, want %q", taskError.Stamp(), lastError.Stamp())
+ }
+
+ session, err := repo.GetTaskSession(context.Background(), "session-launch-failure")
+ if err != nil {
+ t.Fatalf("GetTaskSession: %v", err)
+ }
+ retired, ok := models.LoadLastAgentError(session.Metadata)
+ if !ok || !retired.IsDismissed() {
+ t.Fatalf("session launch error = %#v, want a dismissed correlation marker", retired)
+ }
+}
+
+func TestHandleLaunchFailedKeepsProfileSpecificFailureOnSession(t *testing.T) {
+ repo := setupTestRepo(t)
+ const (
+ taskID = "task-session-owned-launch-failure"
+ sessionID = "session-session-owned-launch-failure"
+ )
+ seedSession(t, repo, taskID, sessionID, "step1")
+ session, err := repo.GetTaskSession(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("GetTaskSession: %v", err)
+ }
+ session.AgentProfileID = "profile-specific"
+ if err := repo.UpdateTaskSession(context.Background(), session); err != nil {
+ t.Fatalf("UpdateTaskSession: %v", err)
+ }
+ lastError := models.LastAgentError{
+ Message: "The agent could not start.",
+ OccurredAt: time.Date(2026, 9, 14, 10, 30, 0, 0, time.UTC),
+ Scope: models.ErrorScopeSession,
+ Code: models.LaunchErrorCategoryGenericLaunchFailure,
+ Details: "profile environment could not be resolved",
+ RecoveryActions: []string{models.RecoveryActionRetryLaunch},
+ StampValue: "profile-specific-launch-failure",
+ }
+ if err := repo.SetSessionMetadataKey(
+ context.Background(), sessionID, models.SessionMetaKeyLastAgentError, lastError,
+ ); err != nil {
+ t.Fatalf("SetSessionMetadataKey: %v", err)
+ }
+
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ messages := &mockMessageCreator{}
+ svc.messageCreator = messages
+ svc.handleLaunchFailed(
+ context.Background(), taskID, sessionID, "repo-1", errors.New("profile env resolution failed"),
+ )
+
+ task, err := repo.GetTask(context.Background(), taskID)
+ if err != nil {
+ t.Fatalf("GetTask: %v", err)
+ }
+ if _, found := models.LoadTaskLaunchError(task.Metadata); found {
+ t.Fatal("profile-specific launch failure was incorrectly promoted to task scope")
+ }
+ updated, err := repo.GetTaskSession(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("GetTaskSession after launch failure: %v", err)
+ }
+ retained, found := models.LoadLastAgentError(updated.Metadata)
+ if !found || retained.IsDismissed() || retained.Stamp() != lastError.Stamp() {
+ t.Fatalf("session launch error = %#v, want active profile-specific marker", retained)
+ }
+ if updated.AgentProfileID != "profile-specific" {
+ t.Fatalf("session profile = %q, want profile-specific", updated.AgentProfileID)
+ }
+ if len(messages.sessionMessages) != 1 {
+ t.Fatalf("session recovery messages = %d, want 1", len(messages.sessionMessages))
+ }
+ if messages.sessionMessages[0].metadata["scope"] != models.ErrorScopeSession {
+ t.Fatalf("recovery message scope = %#v, want session", messages.sessionMessages[0].metadata["scope"])
+ }
+}
+
+func TestClearTaskLaunchRecoverySourceSucceedsWhenRetirementPublishFails(t *testing.T) {
+ ctx := context.Background()
+ const (
+ taskID = "task-session-error-publish"
+ sessionID = "session-error-publish"
+ )
+ repo := setupTestRepo(t)
+ seedSession(t, repo, taskID, sessionID, "step1")
+ lastError := models.LastAgentError{
+ Message: "The agent could not start.",
+ OccurredAt: time.Date(2026, 9, 14, 11, 0, 0, 0, time.UTC),
+ Scope: models.ErrorScopeSession,
+ StampValue: "session-error-publish-stamp",
+ }
+ if err := repo.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyLastAgentError, lastError); err != nil {
+ t.Fatalf("SetSessionMetadataKey: %v", err)
+ }
+ task, err := repo.GetTask(ctx, taskID)
+ if err != nil {
+ t.Fatalf("GetTask: %v", err)
+ }
+ session, err := repo.GetTaskSession(ctx, sessionID)
+ if err != nil {
+ t.Fatalf("GetTaskSession: %v", err)
+ }
+ baseBus := bus.NewMemoryEventBus(testLogger())
+ t.Cleanup(func() { baseBus.Close() })
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ svc.taskLaunchRecoveryRepo = repo
+ svc.eventBus = failingTaskSessionErrorBus{EventBus: baseBus, err: errors.New("event bus unavailable")}
+
+ err = svc.clearTaskLaunchRecoverySource(ctx, &taskLaunchRecoverySource{
+ task: task,
+ session: session,
+ sessionError: lastError,
+ sessionOwned: true,
+ errorStamp: lastError.Stamp(),
+ })
+ if err != nil {
+ t.Fatalf("clearTaskLaunchRecoverySource: %v", err)
+ }
+ updated, err := repo.GetTaskSession(ctx, sessionID)
+ if err != nil {
+ t.Fatalf("reload session: %v", err)
+ }
+ retired, found := models.LoadLastAgentError(updated.Metadata)
+ if !found || !retired.IsDismissed() {
+ t.Fatalf("retired session error = %#v, want dismissed metadata", retired)
+ }
+}
+
func TestRecoverTaskLaunchRejectsStaleStampBeforeMutation(t *testing.T) {
repo := setupTestRepo(t)
seedTaskLaunchRecoveryFixture(t, repo, "task-stale", "task-repo-stale", models.RecoveryActionPickBaseBranch)
@@ -150,7 +367,7 @@ func TestRecoverTaskLaunchRejectsStaleStampBeforeMutation(t *testing.T) {
}
}
-func TestRecoverTaskLaunchRejectsOlderSessionErrorWhenTaskErrorIsNewer(t *testing.T) {
+func TestRecoverTaskLaunchValidatesSessionErrorIndependentlyOfTaskError(t *testing.T) {
repo := setupTestRepo(t)
const taskID = "task-newer-task-error"
const taskRepositoryID = "task-repo-newer-task-error"
@@ -169,6 +386,7 @@ func TestRecoverTaskLaunchRejectsOlderSessionErrorWhenTaskErrorIsNewer(t *testin
if err := repo.SetSessionMetadataKey(context.Background(), "session-older-error", models.SessionMetaKeyLastAgentError, models.LastAgentError{
Message: "older session launch error",
OccurredAt: now.Add(-time.Hour),
+ Scope: models.ErrorScopeSession,
Code: models.LaunchErrorCategoryBaseBranchMissing,
RecoveryActions: []string{models.RecoveryActionPickBaseBranch},
TaskRepositoryID: taskRepositoryID,
@@ -179,15 +397,26 @@ func TestRecoverTaskLaunchRejectsOlderSessionErrorWhenTaskErrorIsNewer(t *testin
fake := &taskLaunchRecoveryServiceFake{branches: []taskservice.Branch{{Name: "main", Type: "remote"}}}
svc := recoveryFixtureService(t, repo, fake)
- _, err := svc.RecoverTaskLaunch(context.Background(), &TaskLaunchRecoveryRequest{
+ req := &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)
+ }
+ source, err := svc.loadTaskLaunchRecoverySource(context.Background(), req)
+ if err != nil {
+ t.Fatalf("loadTaskLaunchRecoverySource: %v", err)
+ }
+ if err := svc.verifyCurrentTaskLaunchRecoverySource(context.Background(), source); err != nil {
+ t.Fatalf("verifyCurrentTaskLaunchRecoverySource: %v", err)
}
if len(fake.updated) != 0 {
- t.Fatalf("older session recovery updated repositories: %#v", fake.updated)
+ t.Fatalf("validation updated repositories: %#v", fake.updated)
+ }
+ task, err := repo.GetTask(context.Background(), taskID)
+ if err != nil {
+ t.Fatalf("GetTask: %v", err)
+ }
+ if taskError, ok := models.LoadTaskLaunchError(task.Metadata); !ok || taskError.Stamp() != "recovery-stamp" {
+ t.Fatalf("task error = %#v, want independent task error", taskError)
}
}
diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go
index 00ed9eb6c6..ea9c784606 100644
--- a/apps/backend/internal/orchestrator/task_operations_test.go
+++ b/apps/backend/internal/orchestrator/task_operations_test.go
@@ -4714,26 +4714,27 @@ func TestIssue1884_StepProfileSignalGateStaysInTaskMode(t *testing.T) {
// mockMessageCreator implements MessageCreator for testing.
// Only CreateUserMessage is tracked; all other methods are no-op stubs.
type mockMessageCreator struct {
- mu sync.Mutex
- userMessages []mockUserMessage
- sessionMessages []mockSessionMessage
- sessionMessageAttempts int
- sessionMessageDone chan struct{}
- sessionMessageOnce sync.Once
- sessionMessageErr error
- agentMessages []mockAgentMessage
- agentMessageWrites int
- agentStreamWrites int
- agentStreamTexts []string
- thinkingWrites int
- toolCallWrites int
- toolUpdateWrites int
- userMessageErr error
- idempotentUserMessages map[string]struct{}
- permissionClaimFn func(context.Context, models.PermissionResolutionClaimRequest) (*models.PermissionResolutionClaimResult, error)
- permissionFinishFn func(context.Context, models.PermissionResolutionFinalizeRequest) (*models.PermissionResolutionFinalizeResult, error)
- permissionAuditFn func(context.Context, string, string, string, string) (*models.PermissionResolutionAudit, error)
- permissionUpdateFn func(context.Context, string, string, string, string, models.PermissionStatus) error
+ mu sync.Mutex
+ userMessages []mockUserMessage
+ sessionMessages []mockSessionMessage
+ sessionMessageAttempts int
+ sessionMessageDone chan struct{}
+ sessionMessageOnce sync.Once
+ sessionMessageErr error
+ idempotentSessionMessages map[string]struct{}
+ agentMessages []mockAgentMessage
+ agentMessageWrites int
+ agentStreamWrites int
+ agentStreamTexts []string
+ thinkingWrites int
+ toolCallWrites int
+ toolUpdateWrites int
+ userMessageErr error
+ idempotentUserMessages map[string]struct{}
+ permissionClaimFn func(context.Context, models.PermissionResolutionClaimRequest) (*models.PermissionResolutionClaimResult, error)
+ permissionFinishFn func(context.Context, models.PermissionResolutionFinalizeRequest) (*models.PermissionResolutionFinalizeResult, error)
+ permissionAuditFn func(context.Context, string, string, string, string) (*models.PermissionResolutionAudit, error)
+ permissionUpdateFn func(context.Context, string, string, string, string, models.PermissionStatus) error
}
type mockUserMessage struct {
@@ -4818,6 +4819,31 @@ func (m *mockMessageCreator) CreateSessionMessage(_ context.Context, taskID, con
return nil
}
+func (m *mockMessageCreator) CreateSessionMessageIdempotent(_ context.Context, messageID, taskID, content, sessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.sessionMessageErr != nil {
+ return m.sessionMessageErr
+ }
+ if m.idempotentSessionMessages == nil {
+ m.idempotentSessionMessages = make(map[string]struct{})
+ }
+ if _, exists := m.idempotentSessionMessages[messageID]; exists {
+ return nil
+ }
+ m.idempotentSessionMessages[messageID] = struct{}{}
+ m.sessionMessages = append(m.sessionMessages, mockSessionMessage{
+ taskID: taskID,
+ content: content,
+ sessionID: sessionID,
+ messageType: messageType,
+ turnID: turnID,
+ metadata: metadata,
+ requestsInput: requestsInput,
+ })
+ return nil
+}
+
func (m *mockMessageCreator) CreatePermissionRequestMessage(context.Context, string, string, string, string, string, string, string, []map[string]interface{}, string, map[string]interface{}) (string, error) {
return "", nil
}
diff --git a/apps/backend/internal/task/handlers/message_handlers_initial_task_brief_test.go b/apps/backend/internal/task/handlers/message_handlers_initial_task_brief_test.go
index 4aaa666440..78134c7a9e 100644
--- a/apps/backend/internal/task/handlers/message_handlers_initial_task_brief_test.go
+++ b/apps/backend/internal/task/handlers/message_handlers_initial_task_brief_test.go
@@ -506,7 +506,7 @@ func TestWSAddMessage_ConcurrentInitialBriefStartsOnlyAdmittedCandidate(t *testi
}
require.Eventually(t, func() bool { return len(orch.queueCalls()) == 1 }, time.Second, time.Millisecond)
- require.Len(t, orch.started, 1)
+ require.Eventually(t, func() bool { return len(orch.started) == 1 }, time.Second, time.Millisecond)
require.Len(t, repo.messages, 2)
queuedCalls := orch.queueCalls()
require.True(t, queuedCalls[0].userMessageRecorded)
diff --git a/apps/backend/internal/task/models/launch_errors.go b/apps/backend/internal/task/models/launch_errors.go
index b6c13ea0b2..139d632d59 100644
--- a/apps/backend/internal/task/models/launch_errors.go
+++ b/apps/backend/internal/task/models/launch_errors.go
@@ -47,6 +47,12 @@ const (
const (
LaunchErrorPhaseBootstrap = "bootstrap"
+ // Error scopes identify the owner of a failure. Session failures belong in
+ // the session transcript; task failures belong to the task shell and remain
+ // visible while the task changes tabs or sessions.
+ ErrorScopeSession = "session"
+ ErrorScopeTask = "task"
+
AgentErrorCauseOperationResume = "resume"
AgentErrorCauseOperationRestoreWorkspace = "restore_workspace"
@@ -138,6 +144,8 @@ func isKnownAgentErrorCauseCode(code string) bool {
type TaskLaunchError struct {
Message string `json:"message"`
OccurredAt time.Time `json:"occurred_at"`
+ Scope string `json:"scope,omitempty"`
+ SessionID string `json:"session_id,omitempty"`
Code string `json:"code,omitempty"`
Details string `json:"details,omitempty"`
RecoveryActions []string `json:"recovery_actions,omitempty"`
@@ -210,6 +218,7 @@ func isKnownRecoveryAction(action string) bool {
}
func normalizeLastAgentError(value LastAgentError) LastAgentError {
+ value.Scope = normalizeErrorScope(value.Scope, ErrorScopeSession)
value.Message = truncateUTF8Bytes(value.Message, maxLaunchErrorMessageBytes)
value.Code = truncateUTF8Bytes(value.Code, maxLaunchErrorCategoryBytes)
value.RecoveryActions = NormalizeRecoveryActionsForCategory(value.Code, value.RecoveryActions)
@@ -233,8 +242,10 @@ func normalizeLastAgentError(value LastAgentError) LastAgentError {
}
func normalizeTaskLaunchError(value TaskLaunchError) TaskLaunchError {
+ value.Scope = normalizeErrorScope(value.Scope, ErrorScopeTask)
value.Message = truncateUTF8Bytes(value.Message, maxLaunchErrorMessageBytes)
value.Code = truncateUTF8Bytes(value.Code, maxLaunchErrorCategoryBytes)
+ value.SessionID = truncateUTF8Bytes(value.SessionID, maxLaunchErrorIDBytes)
value.RecoveryActions = NormalizeRecoveryActionsForCategory(value.Code, value.RecoveryActions)
value.TaskRepositoryID = truncateUTF8Bytes(value.TaskRepositoryID, maxTaskRepositoryIDBytes)
value.StampValue = boundedLaunchErrorStamp(value.StampValue)
@@ -242,6 +253,17 @@ func normalizeTaskLaunchError(value TaskLaunchError) TaskLaunchError {
return value
}
+func normalizeErrorScope(value, fallback string) string {
+ switch strings.TrimSpace(value) {
+ case ErrorScopeSession:
+ return ErrorScopeSession
+ case ErrorScopeTask:
+ return ErrorScopeTask
+ default:
+ return fallback
+ }
+}
+
// LoadTaskLaunchError reads and validates the task-owned launch error.
func LoadTaskLaunchError(metadata map[string]interface{}) (TaskLaunchError, bool) {
if metadata == nil {
diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go
index 2cdb95d1f3..f5c832c114 100644
--- a/apps/backend/internal/task/models/models.go
+++ b/apps/backend/internal/task/models/models.go
@@ -735,6 +735,7 @@ const SessionMetaKeyLastAgentError = "last_agent_error"
type LastAgentError struct {
Message string `json:"message"`
OccurredAt time.Time `json:"occurred_at"`
+ Scope string `json:"scope,omitempty"`
AgentExecutionID string `json:"agent_execution_id,omitempty"`
ExecutionID string `json:"execution_id,omitempty"`
Phase string `json:"phase,omitempty"`
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 d14c48b1df..fbdc70b5e4 100644
--- a/apps/backend/internal/task/service/service_status_summary_rebuild.go
+++ b/apps/backend/internal/task/service/service_status_summary_rebuild.go
@@ -606,6 +606,7 @@ func (s *Service) rebuildInput(
var activeError *statussummary.ActiveErrorSummary
if lastError, ok := models.LoadLastAgentError(session.Metadata); ok && !lastError.IsDismissed() {
activeError = &statussummary.ActiveErrorSummary{
+ Scope: models.ErrorScopeSession,
SessionID: session.ID,
TaskRepositoryID: lastError.TaskRepositoryID,
ExecutionID: lastError.ExecutionID,
@@ -655,10 +656,13 @@ func taskLaunchErrorSummary(task *models.Task) *statussummary.ActiveErrorSummary
return nil
}
return &statussummary.ActiveErrorSummary{
+ Scope: models.ErrorScopeTask,
+ SessionID: errorValue.SessionID,
TaskRepositoryID: errorValue.TaskRepositoryID,
Stamp: errorValue.Stamp(),
OccurredAt: errorValue.OccurredAt,
Preview: errorValue.Message,
+ Details: errorValue.Details,
Category: errorValue.Code,
RecoveryActions: errorValue.RecoveryActions,
}
diff --git a/apps/backend/internal/task/statussummary/model.go b/apps/backend/internal/task/statussummary/model.go
index fc708cdb46..4eafd810b1 100644
--- a/apps/backend/internal/task/statussummary/model.go
+++ b/apps/backend/internal/task/statussummary/model.go
@@ -45,6 +45,7 @@ type TaskStatusSummary struct {
ActiveSubagentCount int `json:"active_subagent_count,omitempty"`
PendingAction string `json:"pending_action,omitempty"`
ActiveError *ActiveErrorSummary `json:"active_error,omitempty"`
+ TaskError *ActiveErrorSummary `json:"task_error,omitempty"`
Git *GitSummary `json:"git,omitempty"`
PullRequest *PullRequestSummary `json:"pull_request,omitempty"`
// QueuedPromptCount is the number of prompts currently en-queued for the
@@ -60,6 +61,7 @@ type PrimarySessionSummary struct {
}
type ActiveErrorSummary struct {
+ Scope string `json:"scope,omitempty"`
SessionID string `json:"session_id,omitempty"`
TaskRepositoryID string `json:"task_repository_id,omitempty"`
ExecutionID string `json:"execution_id,omitempty"`
@@ -136,6 +138,9 @@ func (s TaskStatusSummary) Validate() error {
if err := validateActiveError(s.ActiveError); err != nil {
return err
}
+ if err := validateActiveError(s.TaskError); err != nil {
+ return err
+ }
return validatePullRequest(s.PullRequest)
}
@@ -158,6 +163,7 @@ func validateActiveError(activeError *ActiveErrorSummary) error {
value string
limit int
}{
+ {"active error scope", activeError.Scope, maxActiveErrorCategoryBytes},
{"active error session id", activeError.SessionID, maxSessionIDBytes},
{"active error task repository id", activeError.TaskRepositoryID, maxTaskRepositoryIDBytes},
{"active error execution id", activeError.ExecutionID, maxSessionIDBytes},
@@ -168,6 +174,9 @@ func validateActiveError(activeError *ActiveErrorSummary) error {
{"active error details", activeError.Details, MaxActiveErrorDetailsBytes},
{"active error category", activeError.Category, maxActiveErrorCategoryBytes},
}
+ if activeError.Scope != "" && activeError.Scope != models.ErrorScopeSession && activeError.Scope != models.ErrorScopeTask {
+ return fmt.Errorf("active error has unknown scope")
+ }
for _, field := range fields {
if err := validateUTF8Bytes(field.name, field.value, field.limit); err != nil {
return err
@@ -239,6 +248,7 @@ func (s TaskStatusSummary) SemanticJSON() ([]byte, error) {
ActiveSubagentCount: s.ActiveSubagentCount,
PendingAction: s.PendingAction,
ActiveError: s.ActiveError,
+ TaskError: s.TaskError,
Git: s.Git,
PullRequest: s.PullRequest,
QueuedPromptCount: s.QueuedPromptCount,
@@ -252,6 +262,7 @@ type semanticPayload struct {
ActiveSubagentCount int `json:"active_subagent_count,omitempty"`
PendingAction string `json:"pending_action,omitempty"`
ActiveError *ActiveErrorSummary `json:"active_error,omitempty"`
+ TaskError *ActiveErrorSummary `json:"task_error,omitempty"`
Git *GitSummary `json:"git,omitempty"`
PullRequest *PullRequestSummary `json:"pull_request,omitempty"`
QueuedPromptCount int `json:"queued_prompt_count,omitempty"`
diff --git a/apps/backend/internal/task/statussummary/model_test.go b/apps/backend/internal/task/statussummary/model_test.go
index 577ff6808c..b076773b0e 100644
--- a/apps/backend/internal/task/statussummary/model_test.go
+++ b/apps/backend/internal/task/statussummary/model_test.go
@@ -69,6 +69,11 @@ func TestTaskStatusSummarySemanticJSONIsBoundedAndOmitsTransportMetadata(t *test
Stamp: "error-1",
Preview: "safe preview",
},
+ TaskError: &ActiveErrorSummary{
+ Scope: models.ErrorScopeTask,
+ Stamp: "task-error-1",
+ Preview: "safe task preview",
+ },
Git: &GitSummary{ChangedFiles: 2, Additions: 3},
}
payload, err := summary.SemanticJSON()
@@ -85,6 +90,9 @@ func TestTaskStatusSummarySemanticJSONIsBoundedAndOmitsTransportMetadata(t *test
if !summary.SemanticEqual(decoded) {
t.Fatalf("semantic round trip changed value: %#v", decoded)
}
+ if decoded.TaskError == nil || decoded.TaskError.Scope != models.ErrorScopeTask {
+ t.Fatalf("task error missing from semantic payload: %#v", decoded.TaskError)
+ }
}
func TestTaskStatusSummaryQueuedPromptCountRoundTripsThroughSemanticJSON(t *testing.T) {
@@ -173,6 +181,9 @@ func TestTaskStatusSummaryProjectsTaskOwnedErrorFields(t *testing.T) {
if got.ActiveError.Category != "pr_already_closed" {
t.Fatalf("active error category = %q", got.ActiveError.Category)
}
+ if got.TaskError == nil || got.TaskError.Scope != models.ErrorScopeTask {
+ t.Fatalf("task error = %+v, want independent task projection", got.TaskError)
+ }
wantActions := []string{"mark_review_done"}
if !reflect.DeepEqual(got.ActiveError.RecoveryActions, wantActions) {
t.Fatalf("active error actions = %#v, want %#v", got.ActiveError.RecoveryActions, wantActions)
diff --git a/apps/backend/internal/task/statussummary/projector_derive.go b/apps/backend/internal/task/statussummary/projector_derive.go
index 1ba07c558c..2aabda30ff 100644
--- a/apps/backend/internal/task/statussummary/projector_derive.go
+++ b/apps/backend/internal/task/statussummary/projector_derive.go
@@ -2,6 +2,8 @@ package statussummary
import (
"strings"
+
+ "github.com/kandev/kandev/internal/task/models"
)
func deriveSummary(state *projectionState) TaskStatusSummary {
@@ -12,6 +14,7 @@ func deriveSummary(state *projectionState) TaskStatusSummary {
ActiveSubagentCount: activeSubagentCount,
PendingAction: derivePendingAction(state),
ActiveError: deriveActiveError(state),
+ TaskError: cloneActiveError(state.taskError),
Git: deriveGitSummary(state),
PullRequest: derivePullRequestSummary(state),
QueuedPromptCount: state.queuedCount,
@@ -19,6 +22,16 @@ func deriveSummary(state *projectionState) TaskStatusSummary {
}
}
+func cloneActiveError(value *ActiveErrorSummary) *ActiveErrorSummary {
+ if value == nil {
+ return nil
+ }
+ copy := *value
+ copy.RecoveryActions = append([]string(nil), value.RecoveryActions...)
+ copy.Causes = append([]models.AgentErrorCause(nil), value.Causes...)
+ return ©
+}
+
func deriveSessionFields(state *projectionState) (*PrimarySessionSummary, string, int) {
var primary *sessionObservation
activities := make([]string, 0, len(state.sessions))
@@ -90,7 +103,7 @@ func derivePendingAction(state *projectionState) string {
func deriveActiveError(state *projectionState) *ActiveErrorSummary {
var active *ActiveErrorSummary
if !state.errorsObserved && state.current != nil && state.current.ActiveError != nil &&
- (!state.taskErrorObserved || state.current.ActiveError.SessionID != "") {
+ (!state.taskErrorObserved || activeErrorScope(state.current.ActiveError) == models.ErrorScopeSession) {
copy := *state.current.ActiveError
active = ©
}
@@ -108,6 +121,18 @@ func deriveActiveError(state *projectionState) *ActiveErrorSummary {
return active
}
+// activeErrorScope routes explicit ownership first. Older summaries omitted
+// scope, so their session-id inference remains the compatibility fallback.
+func activeErrorScope(value *ActiveErrorSummary) string {
+ if value != nil && (value.Scope == models.ErrorScopeSession || value.Scope == models.ErrorScopeTask) {
+ return value.Scope
+ }
+ if value != nil && value.SessionID != "" {
+ return models.ErrorScopeSession
+ }
+ return models.ErrorScopeTask
+}
+
func newerActiveError(candidate, current *ActiveErrorSummary) bool {
if candidate == nil {
return false
diff --git a/apps/backend/internal/task/statussummary/projector_events.go b/apps/backend/internal/task/statussummary/projector_events.go
index 5c6e4f1a17..9b0439867c 100644
--- a/apps/backend/internal/task/statussummary/projector_events.go
+++ b/apps/backend/internal/task/statussummary/projector_events.go
@@ -330,12 +330,18 @@ func applySummaryBaseline(state *projectionState, summary *TaskStatusSummary) {
isPrimary: true,
}
}
+ if summary.TaskError != nil {
+ copy := *summary.TaskError
+ copy.RecoveryActions = normalizeRecoveryActionsForCategory(copy.Category, copy.RecoveryActions)
+ state.taskError = ©
+ }
if summary.ActiveError != nil {
copy := *summary.ActiveError
copy.RecoveryActions = normalizeRecoveryActionsForCategory(copy.Category, copy.RecoveryActions)
- if copy.SessionID != "" {
+ scope := activeErrorScope(©)
+ if scope == models.ErrorScopeSession && copy.SessionID != "" {
state.errors[copy.SessionID] = ©
- } else {
+ } else if scope == models.ErrorScopeTask && summary.TaskError == nil {
state.taskError = ©
}
}
diff --git a/apps/backend/internal/task/statussummary/projector_helpers.go b/apps/backend/internal/task/statussummary/projector_helpers.go
index 07c374c824..6a52ec3f73 100644
--- a/apps/backend/internal/task/statussummary/projector_helpers.go
+++ b/apps/backend/internal/task/statussummary/projector_helpers.go
@@ -182,6 +182,7 @@ func errorFromMap(now time.Time, sessionID string, data map[string]interface{})
phase = ""
}
return &ActiveErrorSummary{
+ Scope: normalizeErrorScope(stringField(data, "scope"), sessionID != ""),
SessionID: truncateString(sessionID, maxSessionIDBytes),
TaskRepositoryID: truncateString(stringField(data, "task_repository_id"), maxTaskRepositoryIDBytes),
ExecutionID: truncateString(firstString(data, "execution_id", "agent_execution_id"), maxSessionIDBytes),
@@ -201,7 +202,7 @@ func errorEqual(a, b *ActiveErrorSummary) bool {
if a == nil || b == nil {
return a == b
}
- return a.SessionID == b.SessionID && a.TaskRepositoryID == b.TaskRepositoryID &&
+ return a.Scope == b.Scope && a.SessionID == b.SessionID && a.TaskRepositoryID == b.TaskRepositoryID &&
a.ExecutionID == b.ExecutionID && a.AttemptID == b.AttemptID && a.Phase == b.Phase &&
a.Stamp == b.Stamp && a.OccurredAt.Equal(b.OccurredAt) &&
a.Preview == b.Preview && a.Details == b.Details && a.Category == b.Category &&
diff --git a/apps/backend/internal/task/statussummary/projector_task_error_test.go b/apps/backend/internal/task/statussummary/projector_task_error_test.go
index db4f98e004..43189a72b9 100644
--- a/apps/backend/internal/task/statussummary/projector_task_error_test.go
+++ b/apps/backend/internal/task/statussummary/projector_task_error_test.go
@@ -7,6 +7,7 @@ import (
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
+ "github.com/kandev/kandev/internal/task/models"
)
func taskErrorProjector(t *testing.T, store *projectorTestStore, loader TaskLaunchErrorLoader) *Projector {
@@ -47,14 +48,17 @@ func TestProjectorProjectsTaskOwnedLaunchErrorWithoutSession(t *testing.T) {
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 == nil || got.TaskError == nil {
+ t.Fatalf("summary = %+v, want task-owned error projection", got)
}
- if got.ActiveError.SessionID != "" || got.ActiveError.TaskRepositoryID != "task-repository-1" {
- t.Fatalf("active error identity = %+v", got.ActiveError)
+ if got.TaskError.Scope != "task" || got.TaskError.SessionID != "" || got.TaskError.TaskRepositoryID != "task-repository-1" {
+ t.Fatalf("task error identity = %+v", got.TaskError)
}
- if len(got.ActiveError.RecoveryActions) != 1 || got.ActiveError.RecoveryActions[0] != "mark_review_done" {
- t.Fatalf("active error actions = %#v", got.ActiveError.RecoveryActions)
+ if len(got.TaskError.RecoveryActions) != 1 || got.TaskError.RecoveryActions[0] != "mark_review_done" {
+ t.Fatalf("task error actions = %#v", got.TaskError.RecoveryActions)
+ }
+ if got.ActiveError == nil || got.ActiveError.Scope != "task" {
+ t.Fatalf("active error compatibility projection = %+v", got.ActiveError)
}
}
@@ -123,3 +127,60 @@ func TestProjectorRefreshesTaskLaunchErrorRemoval(t *testing.T) {
t.Fatalf("summary after task error removal = %+v, want no active error", got)
}
}
+
+func TestProjectorClearsTaskScopedErrorWithOriginatingSessionAfterTaskMetadataRemoval(t *testing.T) {
+ store := newProjectorTestStore()
+ originatingSession := "session-origin"
+ storedError := &ActiveErrorSummary{
+ Scope: models.ErrorScopeTask,
+ SessionID: originatingSession,
+ Stamp: "task-error-1",
+ OccurredAt: time.Date(2026, 8, 19, 19, 0, 0, 0, time.UTC),
+ Preview: "task error",
+ }
+ store.rows["task-1"] = &StoredTaskStatusSummary{
+ TaskID: "task-1",
+ WorkspaceID: "workspace-1",
+ Summary: TaskStatusSummary{
+ Revision: 1,
+ ActiveError: storedError,
+ TaskError: storedError,
+ },
+ }
+ active := true
+ projector := taskErrorProjector(t, store, func(context.Context, string) (TaskLaunchErrorObservation, error) {
+ if !active {
+ return TaskLaunchErrorObservation{Observed: true}, nil
+ }
+ return TaskLaunchErrorObservation{
+ Observed: true,
+ Error: storedError,
+ }, 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("restore task error: %v", err)
+ }
+ got := store.summary("task-1")
+ if got == nil || got.TaskError == nil || got.ActiveError == nil {
+ t.Fatalf("summary after restore = %+v, want task error in both projections", got)
+ }
+ if got.TaskError.Scope != models.ErrorScopeTask || got.TaskError.SessionID != originatingSession {
+ t.Fatalf("restored task error = %+v, want explicit task scope", got.TaskError)
+ }
+
+ active = false
+ if err := projector.HandleEvent(context.Background(), event()); err != nil {
+ t.Fatalf("clear task error: %v", err)
+ }
+ got = store.summary("task-1")
+ if got == nil || got.TaskError != nil || got.ActiveError != nil {
+ t.Fatalf("summary after task metadata removal = %+v, want both errors cleared", got)
+ }
+}
diff --git a/apps/backend/internal/task/statussummary/rebuild.go b/apps/backend/internal/task/statussummary/rebuild.go
index bda20cf292..4e8d64b5ba 100644
--- a/apps/backend/internal/task/statussummary/rebuild.go
+++ b/apps/backend/internal/task/statussummary/rebuild.go
@@ -140,6 +140,7 @@ func normalizeRebuildError(input *ActiveErrorSummary, now time.Time) *ActiveErro
copy.OccurredAt = now.UTC()
}
copy.Preview = truncateString(copy.Preview, MaxActiveErrorPreviewBytes)
+ copy.Scope = normalizeErrorScope(copy.Scope, copy.SessionID != "")
copy.SessionID = truncateString(copy.SessionID, maxSessionIDBytes)
copy.TaskRepositoryID = truncateString(copy.TaskRepositoryID, maxTaskRepositoryIDBytes)
copy.ExecutionID = truncateString(copy.ExecutionID, maxSessionIDBytes)
@@ -157,3 +158,13 @@ func normalizeRebuildError(input *ActiveErrorSummary, now time.Time) *ActiveErro
copy.Stamp = truncateString(copy.Stamp, maxActiveErrorStampBytes)
return ©
}
+
+func normalizeErrorScope(scope string, sessionOwned bool) string {
+ if scope == models.ErrorScopeSession || scope == models.ErrorScopeTask {
+ return scope
+ }
+ if sessionOwned {
+ return models.ErrorScopeSession
+ }
+ return models.ErrorScopeTask
+}
diff --git a/apps/web/app/office/tasks/[id]/task-advanced-mode.tsx b/apps/web/app/office/tasks/[id]/task-advanced-mode.tsx
index 11356673b0..b8f560b9fe 100644
--- a/apps/web/app/office/tasks/[id]/task-advanced-mode.tsx
+++ b/apps/web/app/office/tasks/[id]/task-advanced-mode.tsx
@@ -11,6 +11,8 @@ import { ExecutionIndicator } from "../../components/execution-indicator";
import { useOfficeTopbar } from "../../components/office-topbar-context";
import type { Task } from "./types";
import { useTranslation } from "react-i18next";
+import { TaskLaunchErrorProvider } from "@/components/task/task-launch-error-context";
+import { TaskSharedError } from "@/components/task/task-shared-error";
const OfficeDockviewLayout = dynamic(
() => import("./office-dockview-layout").then((m) => ({ default: m.OfficeDockviewLayout })),
@@ -57,14 +59,24 @@ export function TaskAdvancedMode({ task, onToggleSimple }: TaskAdvancedModeProps
});
return (
-
- {isSessionEnded && (
-
-
- {t("office:agentSessionEnded")}
-
- )}
-
-
+
+
+ {isSessionEnded && (
+
+
+ {t("office:agentSessionEnded")}
+
+ )}
+
+
+
+
);
}
diff --git a/apps/web/app/office/tasks/[id]/types.ts b/apps/web/app/office/tasks/[id]/types.ts
index 2410040e2f..5cd6a4e364 100644
--- a/apps/web/app/office/tasks/[id]/types.ts
+++ b/apps/web/app/office/tasks/[id]/types.ts
@@ -95,9 +95,9 @@ export type TaskSession = {
};
/**
- * RunError is a chat-timeline view of a single FAILED office session.
- * Sourced from TaskSession (no separate API call) — derived in
- * task-chat.tsx for entries.kind === "error".
+ * RunError is a chat-timeline view of one retained Office session failure.
+ * Sourced from TaskSession (no separate API call) and derived in task-chat.tsx
+ * for entries.kind === "error".
*/
export type RunError = {
id: string;
@@ -113,6 +113,8 @@ export type RunError = {
recoveryActions?: TaskLaunchRecoveryAction[];
taskRepositoryId?: string;
errorStamp?: string;
+ /** False when the session error is retained as history after recovery. */
+ isActive?: boolean;
};
export type TaskLabelLocal = {
diff --git a/apps/web/components/quick-chat/quick-chat-content.tsx b/apps/web/components/quick-chat/quick-chat-content.tsx
index 7c6d6e952a..4e66cc6154 100644
--- a/apps/web/components/quick-chat/quick-chat-content.tsx
+++ b/apps/web/components/quick-chat/quick-chat-content.tsx
@@ -1,6 +1,6 @@
"use client";
-import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
+import { memo, useCallback, useEffect, useRef, useState } from "react";
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
import { type ChatInputContainerHandle } from "@/components/task/chat/chat-input-container";
import { MessageList } from "@/components/task/chat/message-list";
@@ -21,8 +21,6 @@ type QuickChatContentProps = {
placeholderOverride?: string;
initialPrompt?: string;
onInitialPromptAttempted?: () => void;
- recoveryContent?: ReactNode;
- recoveryRevealKey?: string | null;
};
function useQuickChatState(sessionId: string) {
@@ -52,8 +50,6 @@ export const QuickChatContent = memo(function QuickChatContent({
placeholderOverride,
initialPrompt,
onInitialPromptAttempted,
- recoveryContent,
- recoveryRevealKey,
}: QuickChatContentProps) {
const [clarificationKey, setClarificationKey] = useState(0);
const shortcutScopeRef = useRef(null);
@@ -112,8 +108,6 @@ export const QuickChatContent = memo(function QuickChatContent({
sessionState={panelState.session?.state}
worktreePath={getSessionWorkspacePath(panelState.session)}
onOpenFile={undefined}
- prependContent={recoveryContent}
- recoveryRevealKey={recoveryRevealKey}
/>
[] as Array<{ sessionId: string; taskId?: string; agentProfileId?: string }>,
);
-const quickChatContentProps = vi.hoisted(() => [] as Array<{ recoveryRevealKey?: string | null }>);
const useEnsureTaskSession = vi.hoisted(() => vi.fn());
const useTask = vi.hoisted(() => vi.fn());
const useSessionResumption = vi.hoisted(() =>
@@ -35,6 +33,10 @@ const useSessionResumption = vi.hoisted(() =>
})),
);
const useTaskStatusSummary = vi.hoisted(() => vi.fn());
+const TEST_IDS = vi.hoisted(() => ({
+ sessionRecoveryCard: "session-bootstrap-recovery-card",
+ quickChatContent: "quick-chat-content",
+}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
@@ -53,34 +55,7 @@ vi.mock("@/components/task/passthrough-terminal", () => ({
PassthroughTerminal: () => ,
}));
vi.mock("./quick-chat-content", () => ({
- QuickChatContent: ({
- recoveryContent,
- recoveryRevealKey,
- }: {
- recoveryContent?: ReactNode;
- recoveryRevealKey?: string | null;
- }) => {
- quickChatContentProps.push({ recoveryRevealKey });
- return {recoveryContent}
;
- },
-}));
-vi.mock("@/components/task/chat/session-bootstrap-recovery-card", () => ({
- SessionBootstrapRecoveryCard: ({
- error,
- automaticRecovery,
- }: {
- error: { stamp: string };
- automaticRecovery?: {
- notice: string | null;
- recoveryFailure: { outcome: string } | null;
- };
- }) => (
-
- {error.stamp}
- {automaticRecovery?.notice ?? ""}
- {automaticRecovery?.recoveryFailure?.outcome ?? ""}
-
- ),
+ QuickChatContent: () => ,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
@@ -95,7 +70,6 @@ const BOOTSTRAP_OCCURRED_AT = "2026-09-11T10:00:00Z";
const BOOTSTRAP_PREVIEW = "The agent could not start.";
const WORKSPACE_READ_ONLY_NOTICE = "workspace restored read-only";
const RESUME_FAILURE = "resume failed";
-const RECOVERY_CARD_TEST_ID = "session-bootstrap-recovery-card";
const session = {
kind: "chat" as const,
@@ -107,7 +81,6 @@ afterEach(() => {
cleanup();
delete sessionRows[session.sessionId];
quickChatSessions.length = 0;
- quickChatContentProps.length = 0;
vi.clearAllMocks();
useTask.mockReset();
useTask.mockReturnValue(null);
@@ -190,7 +163,7 @@ describe("QuickChatSessionView session resumption", () => {
);
});
- it("gives a matching bootstrap failure to the inline recovery card", () => {
+ it("keeps a session bootstrap failure in the transcript-owned path", () => {
sessionRows[session.sessionId] = { task_id: HYDRATED_TASK_ID };
useTask.mockReturnValue({ isArchived: false, workspaceId: WORKSPACE_ID });
useTaskStatusSummary.mockReturnValue({
@@ -205,11 +178,11 @@ describe("QuickChatSessionView session resumption", () => {
render();
- expect(screen.getByTestId(RECOVERY_CARD_TEST_ID).textContent).toBe("bootstrap-1");
- expect(screen.getByTestId("quick-chat-content")).toBeTruthy();
+ expect(screen.queryByTestId(TEST_IDS.sessionRecoveryCard)).toBeNull();
+ expect(screen.getByTestId(TEST_IDS.quickChatContent)).toBeTruthy();
});
- it("passes automatic read-only recovery into the inline card", () => {
+ it("does not move session recovery feedback into the transcript", () => {
sessionRows[session.sessionId] = { task_id: HYDRATED_TASK_ID };
useTask.mockReturnValue({ isArchived: false, workspaceId: WORKSPACE_ID });
useTaskStatusSummary.mockReturnValue({
@@ -235,13 +208,11 @@ describe("QuickChatSessionView session resumption", () => {
render();
- expect(screen.getByTestId(RECOVERY_CARD_TEST_ID).textContent).toContain(
- WORKSPACE_READ_ONLY_NOTICE,
- );
- expect(screen.getByTestId(RECOVERY_CARD_TEST_ID).textContent).not.toContain(RESUME_FAILURE);
+ expect(screen.queryByTestId(TEST_IDS.sessionRecoveryCard)).toBeNull();
+ expect(screen.getByTestId(TEST_IDS.quickChatContent)).toBeTruthy();
});
- it("passes automatic recovery failure into the inline card", () => {
+ it("keeps session recovery failure out of the transcript", () => {
sessionRows[session.sessionId] = { task_id: HYDRATED_TASK_ID };
useTask.mockReturnValue({ isArchived: false, workspaceId: WORKSPACE_ID });
useTaskStatusSummary.mockReturnValue({
@@ -271,74 +242,19 @@ describe("QuickChatSessionView session resumption", () => {
render();
- expect(screen.getByTestId(RECOVERY_CARD_TEST_ID).textContent).toContain("recovery_failed");
+ expect(screen.queryByTestId(TEST_IDS.sessionRecoveryCard)).toBeNull();
+ expect(screen.getByTestId(TEST_IDS.quickChatContent)).toBeTruthy();
});
- it("keys unstamped recovery feedback by attempt and outcome while preserving duplicate state", () => {
- const firstFailure = {
- outcome: "recovery_failed" as const,
- resumeError: "first resume failure",
- restoreError: "first restore failure",
- };
- const firstAttempt = {
- resumptionState: "error" as const,
- sessionStatus: null,
- error: "first translated failure",
- notice: null,
- recoveryFailure: firstFailure,
- recoveryAttemptId: 4,
- taskSessionState: "FAILED" as const,
- worktreePath: null,
- worktreeBranch: null,
- resumeSession: vi.fn(),
- };
- useSessionResumption.mockReturnValue(firstAttempt as never);
-
+ it("does not pass a recovery reveal key to Quick Chat content", () => {
const view = render();
- const firstKey = quickChatContentProps.at(-1)?.recoveryRevealKey;
- expect(firstKey).toBe("session-1:recovery:4:recovery_failed");
+ expect(screen.getByTestId(TEST_IDS.quickChatContent)).toBeTruthy();
- useSessionResumption.mockReturnValue({
- ...firstAttempt,
- error: "same attempt, different translated failure",
- } as never);
view.rerender();
- expect(quickChatContentProps.at(-1)?.recoveryRevealKey).toBe(firstKey);
-
- useSessionResumption.mockReturnValue({
- ...firstAttempt,
- recoveryFailure: {
- outcome: "status_unavailable" as const,
- kind: "timeout" as const,
- statusError: "status request failed",
- },
- } as never);
- view.rerender();
- expect(quickChatContentProps.at(-1)?.recoveryRevealKey).toBe(
- "session-1:recovery:4:status_unavailable",
- );
-
- useSessionResumption.mockReturnValue({
- ...firstAttempt,
- error: null,
- recoveryFailure: null,
- recoveryAttemptId: 4,
- } as never);
- view.rerender();
- expect(quickChatContentProps.at(-1)?.recoveryRevealKey).toBeNull();
-
- useSessionResumption.mockReturnValue({
- ...firstAttempt,
- error: "second translated failure",
- recoveryAttemptId: 5,
- } as never);
- view.rerender();
- expect(quickChatContentProps.at(-1)?.recoveryRevealKey).toBe(
- "session-1:recovery:5:recovery_failed",
- );
+ expect(screen.getByTestId(TEST_IDS.quickChatContent)).toBeTruthy();
});
- it("keeps the bootstrap recovery card and automatic outcome in passthrough Quick Chat", () => {
+ it("keeps passthrough recovery feedback outside the terminal", () => {
sessionRows[session.sessionId] = { task_id: HYDRATED_TASK_ID, is_passthrough: true };
useTask.mockReturnValue({ isArchived: false, workspaceId: WORKSPACE_ID });
useTaskStatusSummary.mockReturnValue({
@@ -364,10 +280,11 @@ describe("QuickChatSessionView session resumption", () => {
render();
- expect(screen.getByTestId(RECOVERY_CARD_TEST_ID).textContent).toContain(
+ expect(screen.queryByTestId(TEST_IDS.sessionRecoveryCard)).toBeNull();
+ expect(screen.getByTestId("session-recovery-notice").textContent).toContain(
WORKSPACE_READ_ONLY_NOTICE,
);
expect(screen.getByTestId("passthrough-terminal")).toBeTruthy();
- expect(screen.queryByTestId("quick-chat-content")).toBeNull();
+ expect(screen.queryByTestId(TEST_IDS.quickChatContent)).toBeNull();
});
});
diff --git a/apps/web/components/quick-chat/quick-chat-session-view.tsx b/apps/web/components/quick-chat/quick-chat-session-view.tsx
index 81d856a29c..d985149353 100644
--- a/apps/web/components/quick-chat/quick-chat-session-view.tsx
+++ b/apps/web/components/quick-chat/quick-chat-session-view.tsx
@@ -7,15 +7,12 @@ import { useSessionResumption } from "@/hooks/domains/session/use-session-resump
import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary";
import { PassthroughTerminal } from "@/components/task/passthrough-terminal";
import { SessionRecoveryFeedback } from "@/components/task/ensure-session-error";
-import { SessionBootstrapRecoveryCard } from "@/components/task/chat/session-bootstrap-recovery-card";
-import { selectSessionRecoveryError } from "@/lib/session-recovery-presentation";
+import { TaskLaunchErrorProvider } from "@/components/task/task-launch-error-context";
+import { TaskSharedError } from "@/components/task/task-shared-error";
import type { QuickChatSession } from "@/lib/state/slices/ui/types";
import { QuickChatContent } from "./quick-chat-content";
import { useTranslation } from "react-i18next";
-// i18n-exempt: internal recovery identity token, not user-facing copy.
-const GENERIC_RECOVERY_OUTCOME = "feedback";
-
function useIsQuickChatPassthrough(sessionId: string) {
return useAppStore((state) => {
const session = state.taskSessions.items[sessionId];
@@ -45,33 +42,6 @@ function resolveTaskArchiveState(
return quickChatTaskId === taskId ? false : null;
}
-function resolveRecoveryRevealKey(
- sessionId: string,
- bootstrapRecoveryError: { stamp: string; occurred_at: string } | null,
- hasRecoveryFeedback: boolean,
- recoveryAttemptId: number | undefined,
- recoveryOutcome: string | null,
-): string | null {
- if (bootstrapRecoveryError) {
- return `${sessionId}:${bootstrapRecoveryError.stamp || bootstrapRecoveryError.occurred_at}`;
- }
- if (!hasRecoveryFeedback) return null;
- return `${sessionId}:recovery:${recoveryAttemptId ?? 0}:${recoveryOutcome ?? GENERIC_RECOVERY_OUTCOME}`;
-}
-
-function hasRecoveryFeedback(state: {
- error: string | null;
- notice: string | null;
- recoveryFailure: unknown;
-}): boolean {
- return Boolean(state.error) || Boolean(state.notice) || state.recoveryFailure !== null;
-}
-
-function recoveryOutcome(failure: { outcome: string } | null): string | null {
- if (!failure) return null;
- return failure.outcome;
-}
-
export function QuickChatSessionView({
session,
onInitialPromptAttempted,
@@ -91,13 +61,6 @@ export function QuickChatSessionView({
const resumption = useSessionResumption(taskId, session.sessionId, taskArchiveState);
const isPassthrough = useIsQuickChatPassthrough(session.sessionId);
const statusSummary = useTaskStatusSummary(taskId, task?.statusSummary);
- const bootstrapRecoveryError = taskId
- ? selectSessionRecoveryError(
- statusSummary?.active_error,
- session.sessionId,
- taskSession?.metadata,
- )
- : null;
const recoveryFeedback = (
);
- const recoverySurface = bootstrapRecoveryError ? (
-
- ) : (
- recoveryFeedback
- );
- const recoveryRevealKey = resolveRecoveryRevealKey(
- session.sessionId,
- bootstrapRecoveryError,
- hasRecoveryFeedback(resumption),
- resumption.recoveryAttemptId,
- recoveryOutcome(resumption.recoveryFailure),
- );
- if (isPassthrough) {
- return (
-
- {recoverySurface}
-
+ const sessionContent = isPassthrough ? (
+
+ {recoveryFeedback}
+
- );
- }
- const isConfig = session.kind === "config";
- return (
+
+ ) : (
);
+
+ if (!taskId) return sessionContent;
+ if (isPassthrough) {
+ return (
+
+
+
+ {sessionContent}
+
+
+ );
+ }
+ return (
+
+
+
+ {sessionContent}
+
+
+ );
}
diff --git a/apps/web/components/task/chat/message-list-native-scroll.test.ts b/apps/web/components/task/chat/message-list-native-scroll.test.ts
index ddfc20052f..33aa445343 100644
--- a/apps/web/components/task/chat/message-list-native-scroll.test.ts
+++ b/apps/web/components/task/chat/message-list-native-scroll.test.ts
@@ -4,24 +4,11 @@ import { resolveCompetingInitialScrollOwner } from "./message-list-native-scroll
const noProgrammaticLock = () => false;
describe("resolveCompetingInitialScrollOwner", () => {
- it("gives a new recovery failure the initial reveal before unread placement", () => {
- expect(
- resolveCompetingInitialScrollOwner({
- hasPendingLayoutRestore: false,
- hasExplicitScrollTarget: false,
- hasRecoveryReveal: true,
- hasUnreadDivider: true,
- isProgrammaticScrollLocked: noProgrammaticLock,
- }),
- ).toBe("recovery");
- });
-
it("preserves layout and explicit message placement precedence", () => {
expect(
resolveCompetingInitialScrollOwner({
hasPendingLayoutRestore: true,
hasExplicitScrollTarget: true,
- hasRecoveryReveal: true,
hasUnreadDivider: true,
isProgrammaticScrollLocked: noProgrammaticLock,
}),
@@ -30,10 +17,28 @@ describe("resolveCompetingInitialScrollOwner", () => {
resolveCompetingInitialScrollOwner({
hasPendingLayoutRestore: false,
hasExplicitScrollTarget: true,
- hasRecoveryReveal: true,
hasUnreadDivider: true,
isProgrammaticScrollLocked: noProgrammaticLock,
}),
).toBe("explicit-target");
});
+
+ it("uses the unread divider and then the programmatic owner for ordinary placement", () => {
+ expect(
+ resolveCompetingInitialScrollOwner({
+ hasPendingLayoutRestore: false,
+ hasExplicitScrollTarget: false,
+ hasUnreadDivider: true,
+ isProgrammaticScrollLocked: noProgrammaticLock,
+ }),
+ ).toBe("unread-divider");
+ expect(
+ resolveCompetingInitialScrollOwner({
+ hasPendingLayoutRestore: false,
+ hasExplicitScrollTarget: false,
+ hasUnreadDivider: false,
+ isProgrammaticScrollLocked: () => true,
+ }),
+ ).toBe("programmatic-scroll");
+ });
});
diff --git a/apps/web/components/task/chat/message-list-native-scroll.ts b/apps/web/components/task/chat/message-list-native-scroll.ts
index 8486a23e5b..a33f450037 100644
--- a/apps/web/components/task/chat/message-list-native-scroll.ts
+++ b/apps/web/components/task/chat/message-list-native-scroll.ts
@@ -45,7 +45,6 @@ const SCROLL_KEYS = new Set([
"PageUp",
" ",
]);
-const RECOVERY_SCROLL_KEYS = new Set(["ArrowDown", "End", "PageDown", " "]);
/** Writes a clamped maximum so the browser resolves the native bottom without
* forcing a synchronous scrollHeight layout read. */
@@ -234,112 +233,31 @@ type PrependScrollState = {
anchorTop: number | null;
};
-type RecoveryRevealState = {
- key: string | null;
- keepAtTop: boolean;
-};
-
-/** Transfers recovery scroll ownership only after a real downward gesture. */
-function useRecoveryPrependScrollOwnership({
+/** Captures the reader's current position between older-history requests. */
+function usePrependScrollStateCapture({
scrollRef,
- recoveryRevealStateRef,
isLoadingMoreRef,
olderLoadPendingRef,
newestItemKeyRef,
scrollState,
}: {
scrollRef: React.RefObject
;
- recoveryRevealStateRef: React.MutableRefObject;
isLoadingMoreRef: React.MutableRefObject;
olderLoadPendingRef: React.MutableRefObject;
newestItemKeyRef: React.MutableRefObject;
scrollState: React.MutableRefObject;
}) {
useEffect(() => {
- const el = scrollRef.current;
- if (!el) return;
- let userScrollIntentUntil = 0;
- let pointerActive = false;
- let touchActive = false;
- let touchStartY: number | null = null;
- const markUserScrollIntent = () => {
- userScrollIntentUntil = Date.now() + USER_SCROLL_INTENT_WINDOW_MS;
- };
- const onWheel = (event: WheelEvent) => {
- if (event.deltaY > 0) markUserScrollIntent();
- };
- const onKeyDown = (event: KeyboardEvent) => {
- if (RECOVERY_SCROLL_KEYS.has(event.key)) markUserScrollIntent();
- };
- const onPointerDown = () => {
- pointerActive = true;
- markUserScrollIntent();
- };
- const onPointerUp = () => {
- pointerActive = false;
- };
- const onTouchStart = (event: TouchEvent) => {
- touchActive = true;
- touchStartY = event.touches[0]?.clientY ?? null;
- };
- const onTouchMove = (event: TouchEvent) => {
- const nextY = event.touches[0]?.clientY;
- if (touchStartY !== null && nextY !== undefined && nextY < touchStartY) {
- markUserScrollIntent();
- }
- };
- const onTouchEnd = () => {
- touchActive = false;
- touchStartY = null;
- };
- const onScroll = () => {
- const recoveryScrolledAway =
- recoveryRevealStateRef.current.keepAtTop &&
- el.scrollTop > 0 &&
- (userScrollIntentUntil >= Date.now() || pointerActive || touchActive);
- if (recoveryScrolledAway) {
- recoveryRevealStateRef.current.keepAtTop = false;
- if (olderLoadPendingRef.current || isLoadingMoreRef.current) {
- scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current);
- }
- }
- // Native overflow anchoring can adjust scrollTop while the older page is
- // being inserted. Keep the pre-request baseline until our layout effect
- // has restored the visual position explicitly.
+ const element = scrollRef.current;
+ if (!element) return;
+ const capture = () => {
if (olderLoadPendingRef.current || isLoadingMoreRef.current) return;
- scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current);
+ scrollState.current = capturePrependScrollState(element, newestItemKeyRef.current);
};
- scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current);
- el.addEventListener("wheel", onWheel, { passive: true });
- el.addEventListener("keydown", onKeyDown);
- el.addEventListener("pointerdown", onPointerDown, { passive: true });
- el.addEventListener("pointerup", onPointerUp, { passive: true });
- el.addEventListener("pointercancel", onPointerUp, { passive: true });
- el.addEventListener("touchstart", onTouchStart, { passive: true });
- el.addEventListener("touchmove", onTouchMove, { passive: true });
- el.addEventListener("touchend", onTouchEnd, { passive: true });
- el.addEventListener("touchcancel", onTouchEnd, { passive: true });
- el.addEventListener("scroll", onScroll, { passive: true });
- return () => {
- el.removeEventListener("wheel", onWheel);
- el.removeEventListener("keydown", onKeyDown);
- el.removeEventListener("pointerdown", onPointerDown);
- el.removeEventListener("pointerup", onPointerUp);
- el.removeEventListener("pointercancel", onPointerUp);
- el.removeEventListener("touchstart", onTouchStart);
- el.removeEventListener("touchmove", onTouchMove);
- el.removeEventListener("touchend", onTouchEnd);
- el.removeEventListener("touchcancel", onTouchEnd);
- el.removeEventListener("scroll", onScroll);
- };
- }, [
- isLoadingMoreRef,
- newestItemKeyRef,
- olderLoadPendingRef,
- recoveryRevealStateRef,
- scrollRef,
- scrollState,
- ]);
+ capture();
+ element.addEventListener("scroll", capture, { passive: true });
+ return () => element.removeEventListener("scroll", capture);
+ }, [isLoadingMoreRef, newestItemKeyRef, olderLoadPendingRef, scrollRef, scrollState]);
}
/**
@@ -359,7 +277,6 @@ function useScrollPositionOnPrepend(
items: RenderItem[],
isLoadingMore: boolean,
isProgrammaticScrollLocked: () => boolean,
- recoveryRevealKey: string | null,
): () => void {
const scrollState = useRef({
scrollHeight: 0,
@@ -370,22 +287,11 @@ function useScrollPositionOnPrepend(
const isLoadingMoreRef = useRef(isLoadingMore);
const olderLoadPendingRef = useRef(false);
const newestItemKeyRef = useRef(getNewestNonSyntheticItemKey(items));
- const recoveryRevealStateRef = useRef({
- key: recoveryRevealKey,
- keepAtTop: recoveryRevealKey !== null,
- });
- if (recoveryRevealStateRef.current.key !== recoveryRevealKey) {
- recoveryRevealStateRef.current = {
- key: recoveryRevealKey,
- keepAtTop: recoveryRevealKey !== null,
- };
- }
isLoadingMoreRef.current = isLoadingMore;
if (isLoadingMore) olderLoadPendingRef.current = true;
newestItemKeyRef.current = getNewestNonSyntheticItemKey(items);
- useRecoveryPrependScrollOwnership({
+ usePrependScrollStateCapture({
scrollRef,
- recoveryRevealStateRef,
isLoadingMoreRef,
olderLoadPendingRef,
newestItemKeyRef,
@@ -417,11 +323,6 @@ function useScrollPositionOnPrepend(
prevFirstKeyRef.current = nextFirstKey;
if (olderLoadSettled) olderLoadPendingRef.current = false;
if (!el || !prepend || isProgrammaticScrollLocked()) return;
- if (recoveryRevealStateRef.current.key !== null && recoveryRevealStateRef.current.keepAtTop) {
- if (el.scrollTop !== 0) el.scrollTop = 0;
- scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current);
- return;
- }
const prev = scrollState.current;
const anchor = findMessageRow(el, prev.anchorKey);
if (anchor && prev.anchorTop !== null) {
@@ -1311,8 +1212,6 @@ type InitialScrollApplyParams = {
isNearBottomRef: React.RefObject;
envSwitchPlacementToken: number | null;
hasUnreadDivider: boolean;
- recoveryRevealKey: string | null;
- recoveryPlacementPendingRef: React.RefObject;
isProgrammaticScrollLocked: () => boolean;
phase: "provisional" | "final";
};
@@ -1320,11 +1219,9 @@ type InitialScrollApplyParams = {
function markInitialScrollConsumed(
didInitialScroll: React.RefObject,
activationPendingRef: React.RefObject,
- recoveryPlacementPendingRef: React.RefObject,
): void {
didInitialScroll.current = true;
activationPendingRef.current = false;
- recoveryPlacementPendingRef.current = false;
}
function completeEnvSwitchPlacement(envSwitchPlacementToken: number | null): void {
@@ -1348,30 +1245,23 @@ function isCurrentEnvSwitchPlacement(
export type CompetingInitialScrollOwner =
| "layout-restore"
| "explicit-target"
- | "recovery"
| "unread-divider"
| "programmatic-scroll";
export function resolveCompetingInitialScrollOwner(params: {
hasPendingLayoutRestore: boolean;
hasExplicitScrollTarget: boolean;
- hasRecoveryReveal: boolean;
hasUnreadDivider: boolean;
isProgrammaticScrollLocked: () => boolean;
}): CompetingInitialScrollOwner | null {
if (params.hasPendingLayoutRestore) return "layout-restore";
if (params.hasExplicitScrollTarget) return "explicit-target";
- if (params.hasRecoveryReveal) return "recovery";
if (params.hasUnreadDivider) return "unread-divider";
if (params.isProgrammaticScrollLocked()) return "programmatic-scroll";
return null;
}
-function resolveInitialPlacementOwner(
- enabled: boolean,
- recoveryRevealKey: string | null,
-): "recovery" | "bottom" | "saved-position" {
- if (recoveryRevealKey !== null) return "recovery";
+function resolveInitialPlacementOwner(enabled: boolean): "bottom" | "saved-position" {
return enabled ? "bottom" : "saved-position";
}
@@ -1381,13 +1271,12 @@ function reportInitialPlacement(params: {
sessionId: string | null;
token: number | null;
enabled: boolean;
- recoveryRevealKey: string | null;
}): void {
if (!isDebug()) return;
placementDebug(`${params.phase} placement`, {
sessionId: params.sessionId,
token: params.token,
- owner: resolveInitialPlacementOwner(params.enabled, params.recoveryRevealKey),
+ owner: resolveInitialPlacementOwner(params.enabled),
scrollTop: params.element.scrollTop,
scrollHeight: params.element.scrollHeight,
clientHeight: params.element.clientHeight,
@@ -1434,8 +1323,6 @@ function applyInitialScrollPosition(params: InitialScrollApplyParams): void {
isNearBottomRef,
envSwitchPlacementToken,
hasUnreadDivider,
- recoveryRevealKey,
- recoveryPlacementPendingRef,
isProgrammaticScrollLocked,
phase,
} = params;
@@ -1455,18 +1342,17 @@ function applyInitialScrollPosition(params: InitialScrollApplyParams): void {
const competingOwner = resolveCompetingInitialScrollOwner({
hasPendingLayoutRestore,
hasExplicitScrollTarget,
- hasRecoveryReveal: recoveryRevealKey !== null,
hasUnreadDivider,
isProgrammaticScrollLocked,
});
- if (competingOwner && competingOwner !== "recovery") {
+ if (competingOwner) {
if (phase === "provisional") return;
placementDebug(`${phase} placement delegated`, {
sessionId,
owner: competingOwner,
token: envSwitchPlacementToken,
});
- markInitialScrollConsumed(didInitialScroll, activationPendingRef, recoveryPlacementPendingRef);
+ markInitialScrollConsumed(didInitialScroll, activationPendingRef);
completeEnvSwitchPlacement(envSwitchPlacementToken);
return;
}
@@ -1475,17 +1361,14 @@ function applyInitialScrollPosition(params: InitialScrollApplyParams): void {
getStoredAutoScrollTop(sessionId) ??
undefined)
: undefined;
- const scrollTop =
- competingOwner === "recovery"
- ? 0
- : resolveNativeInitialScrollTop({
- enabled,
- hasPendingLayoutRestore,
- savedScrollTop,
- scrollHeight: element.scrollHeight,
- });
+ const scrollTop = resolveNativeInitialScrollTop({
+ enabled,
+ hasPendingLayoutRestore,
+ savedScrollTop,
+ scrollHeight: element.scrollHeight,
+ });
if (phase === "final") {
- markInitialScrollConsumed(didInitialScroll, activationPendingRef, recoveryPlacementPendingRef);
+ markInitialScrollConsumed(didInitialScroll, activationPendingRef);
}
if (scrollTop === null) {
completeFinalEnvSwitchPlacement(phase, envSwitchPlacementToken);
@@ -1507,7 +1390,6 @@ function applyInitialScrollPosition(params: InitialScrollApplyParams): void {
sessionId,
token: envSwitchPlacementToken,
enabled,
- recoveryRevealKey,
});
finishAppliedInitialPlacement({
phase,
@@ -1528,31 +1410,16 @@ function shouldSkipProvisionalPlacement(
return phase === "provisional" && (hasUnreadDivider || appliedToken === currentToken);
}
-function useInitialPlacementLatches(
- envSwitchPlacementToken: number | null,
- recoveryRevealKey: string | null,
-) {
+function useInitialPlacementLatches(envSwitchPlacementToken: number | null) {
const didInitialScroll = useRef(false);
const lastTokenRef = useRef(null);
const provisionalTokenRef = useRef(null);
- const lastRecoveryRevealKeyRef = useRef(null);
- const recoveryPlacementPendingRef = useRef(recoveryRevealKey !== null);
if (envSwitchPlacementToken !== null && envSwitchPlacementToken !== lastTokenRef.current) {
lastTokenRef.current = envSwitchPlacementToken;
provisionalTokenRef.current = null;
didInitialScroll.current = false;
}
- if (recoveryRevealKey !== null && recoveryRevealKey !== lastRecoveryRevealKeyRef.current) {
- lastRecoveryRevealKeyRef.current = recoveryRevealKey;
- provisionalTokenRef.current = null;
- didInitialScroll.current = false;
- recoveryPlacementPendingRef.current = true;
- }
- if (recoveryRevealKey === null) {
- lastRecoveryRevealKeyRef.current = null;
- recoveryPlacementPendingRef.current = false;
- }
- return { didInitialScroll, provisionalTokenRef, recoveryPlacementPendingRef };
+ return { didInitialScroll, provisionalTokenRef };
}
type InitialPlacementLatches = ReturnType;
@@ -1569,7 +1436,6 @@ type InitialScrollPositionParams = {
envSwitchPlacementToken: number | null;
isRestoringLayout: boolean;
isProgrammaticScrollLocked: () => boolean;
- recoveryRevealKey: string | null;
placementLatches: InitialPlacementLatches;
};
@@ -1579,7 +1445,6 @@ type InitialScrollPositionEffectParams = Omit;
isVisibleRef: React.RefObject;
activationPendingRef: React.RefObject;
- recoveryPlacementPendingRef: React.RefObject;
};
function runInitialScrollPositionEffect(
@@ -1597,19 +1462,17 @@ function runInitialScrollPositionEffect(
envSwitchPlacementToken,
isRestoringLayout,
isProgrammaticScrollLocked,
- recoveryRevealKey,
storeApi,
didInitialScroll,
provisionalTokenRef,
isVisibleRef,
activationPendingRef,
- recoveryPlacementPendingRef,
} = params;
if (!isVisible || (envSwitchPlacementToken !== null && isRestoringLayout)) return;
- const hasEmptyTranscript = itemCount === 0 && recoveryRevealKey === null;
+ const hasEmptyTranscript = itemCount === 0;
if (envSwitchPlacementToken !== null && hasEmptyTranscript) {
if (historyRefreshPending) return;
- markInitialScrollConsumed(didInitialScroll, activationPendingRef, recoveryPlacementPendingRef);
+ markInitialScrollConsumed(didInitialScroll, activationPendingRef);
completeEnvSwitchPlacement(envSwitchPlacementToken);
return;
}
@@ -1640,8 +1503,6 @@ function runInitialScrollPositionEffect(
isNearBottomRef,
envSwitchPlacementToken,
hasUnreadDivider,
- recoveryRevealKey,
- recoveryPlacementPendingRef,
isProgrammaticScrollLocked,
phase,
});
@@ -1664,11 +1525,10 @@ function useInitialScrollPosition({
envSwitchPlacementToken,
isRestoringLayout,
isProgrammaticScrollLocked,
- recoveryRevealKey,
placementLatches,
}: InitialScrollPositionParams) {
const storeApi = useAppStoreApi();
- const { didInitialScroll, provisionalTokenRef, recoveryPlacementPendingRef } = placementLatches;
+ const { didInitialScroll, provisionalTokenRef } = placementLatches;
const { isVisibleRef, activationPendingRef } = useActivationPending(
isVisible,
envSwitchPlacementToken,
@@ -1687,13 +1547,11 @@ function useInitialScrollPosition({
envSwitchPlacementToken,
isRestoringLayout,
isProgrammaticScrollLocked,
- recoveryRevealKey,
storeApi,
didInitialScroll,
provisionalTokenRef,
isVisibleRef,
activationPendingRef,
- recoveryPlacementPendingRef,
}),
[
itemCount,
@@ -1707,7 +1565,6 @@ function useInitialScrollPosition({
envSwitchPlacementToken,
isRestoringLayout,
isProgrammaticScrollLocked,
- recoveryRevealKey,
scrollRef,
],
);
@@ -1728,7 +1585,6 @@ type NativeScrollManagementParams = {
isLoadingMore: boolean;
loadMore: () => Promise;
isVisible: boolean;
- recoveryRevealKey?: string | null;
};
/**
@@ -1755,7 +1611,6 @@ export function useNativeScrollManagement(params: NativeScrollManagementParams)
isLoadingMore,
loadMore,
isVisible,
- recoveryRevealKey = null,
} = params;
const pendingChatInitialPlacement = useDockviewStore(
(state) => state.pendingChatInitialPlacement,
@@ -1763,9 +1618,8 @@ export function useNativeScrollManagement(params: NativeScrollManagementParams)
const isRestoringLayout = useDockviewStore((state) => state.isRestoringLayout);
const envSwitchPlacementToken =
pendingChatInitialPlacement?.sessionId === sessionId ? pendingChatInitialPlacement.token : null;
- const placementLatches = useInitialPlacementLatches(envSwitchPlacementToken, recoveryRevealKey);
- const initialPlacementPending =
- envSwitchPlacementToken !== null || placementLatches.recoveryPlacementPendingRef.current;
+ const placementLatches = useInitialPlacementLatches(envSwitchPlacementToken);
+ const initialPlacementPending = envSwitchPlacementToken !== null;
const programmaticScrollLockRef = useRef(false);
const isProgrammaticScrollLocked = useCallback(() => programmaticScrollLockRef.current, []);
const { isNearBottomRef, resyncIsNearBottom, markNotNearBottom } = useAutoScroll({
@@ -1790,7 +1644,6 @@ export function useNativeScrollManagement(params: NativeScrollManagementParams)
items,
isLoadingMore,
isProgrammaticScrollLocked,
- recoveryRevealKey,
);
const loadMoreWithPrependBaseline = useCallback(() => {
beginOlderLoad();
@@ -1825,7 +1678,6 @@ export function useNativeScrollManagement(params: NativeScrollManagementParams)
envSwitchPlacementToken,
isRestoringLayout,
isProgrammaticScrollLocked,
- recoveryRevealKey,
placementLatches,
});
diff --git a/apps/web/components/task/chat/message-list-native.test.tsx b/apps/web/components/task/chat/message-list-native.test.tsx
index 22668b1fd7..dcac7880f2 100644
--- a/apps/web/components/task/chat/message-list-native.test.tsx
+++ b/apps/web/components/task/chat/message-list-native.test.tsx
@@ -86,11 +86,6 @@ const HARNESS_RENDER_ERROR = "harness did not render";
const NATIVE_SCROLL_MANAGEMENT_TEST_ID = "native-scroll-management-container";
const AUTO_SCROLL_CONTAINER_TEST_ID = "auto-scroll-container";
const CACHED_MESSAGE_ID = "cached-message";
-const RECOVERY_SESSION_ID = "session-recovery";
-const RECOVERY_MESSAGE_ID = "recovery-message";
-const OLDEST_ACTIVITY_ID = "activity-old";
-const NEWEST_MESSAGE_ID = "newest";
-const OLDER_MESSAGE_ID = "older";
const TEST_MESSAGES = [{} as Message];
/** Always returns false: the harness never locks programmatic scrolling. */
const NEVER_LOCKED = () => false;
@@ -114,7 +109,6 @@ function Harness({
enabled = true,
sessionId = null,
isProgrammaticScrollLocked = NEVER_LOCKED,
- recoveryRevealKey = null,
}: {
itemCount: number;
anchoredBarOffsetPx: number;
@@ -127,7 +121,6 @@ function Harness({
enabled?: boolean;
sessionId?: string | null;
isProgrammaticScrollLocked?: () => boolean;
- recoveryRevealKey?: string | null;
}) {
const scrollRef = useRef(null);
useLayoutEffect(() => {
@@ -155,7 +148,6 @@ function Harness({
sessionId,
isProgrammaticScrollLocked,
isVisible,
- recoveryRevealKey,
} as Parameters[4];
useScrollToDividerOrBottom(scrollRef, itemCount, dividerKey, anchoredBarOffsetPx, scrollOptions);
return (
@@ -290,7 +282,6 @@ function NativeScrollManagementHarness({
historyRefreshPending = false,
hasUnreadDivider = false,
hasMore = true,
- recoveryRevealKey = null,
}: {
items: RenderItem[];
messages?: Message[];
@@ -304,7 +295,6 @@ function NativeScrollManagementHarness({
historyRefreshPending?: boolean;
hasUnreadDivider?: boolean;
hasMore?: boolean;
- recoveryRevealKey?: string | null;
}) {
const scrollRef = useRef(null);
useNativeScrollMetrics(scrollRef, metrics);
@@ -322,7 +312,6 @@ function NativeScrollManagementHarness({
loadMore,
isVisible,
historyRefreshPending,
- recoveryRevealKey,
});
if (recoveryRef) recoveryRef.current = showRecovery;
return (
@@ -371,301 +360,6 @@ describe("isElementInPreloadRegion", () => {
// eslint-disable-next-line max-lines-per-function -- pagination invariants share one fixture and lifecycle.
describe("useNativeScrollManagement transcript pagination", () => {
- it("reveals recovery at the top once per error stamp and preserves same-stamp scrolling", () => {
- const metrics = { scrollHeight: 1000, scrollTop: 240, clientHeight: 400 };
- const { rerender } = render(
- ,
- );
-
- expect(metrics.scrollTop).toBe(0);
-
- metrics.scrollTop = 240;
- rerender(
- ,
- );
- expect(metrics.scrollTop).toBe(240);
-
- rerender(
- ,
- );
- expect(metrics.scrollTop).toBe(240);
-
- rerender(
- ,
- );
- expect(metrics.scrollTop).toBe(0);
-
- metrics.scrollTop = 240;
- rerender(
- ,
- );
- expect(metrics.scrollTop).toBe(0);
- });
-
- // eslint-disable-next-line max-lines-per-function -- recovery ownership and the subsequent prepend handoff share one controlled lifecycle.
- it("keeps recovery visible while an older page settles, then resumes normal anchoring after user scroll", async () => {
- const page = Promise.withResolvers();
- const metrics = { scrollHeight: 100, scrollTop: 0, clientHeight: 50 };
- const activity = transcriptActivity(OLDEST_ACTIVITY_ID, "turn-1");
- const newest = transcriptMessage(NEWEST_MESSAGE_ID);
- const { rerender } = render(
- page.promise}
- recoveryRevealKey="session-recovery:failure-1"
- />,
- );
- const scroller = screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID);
- const wrappedLoadMore = sharedSentinelCalls.at(-1)?.[4] as () => Promise;
-
- const pendingLoad = wrappedLoadMore();
- rerender(
- page.promise}
- recoveryRevealKey="session-recovery:failure-1"
- />,
- );
- metrics.scrollHeight = 200;
- rerender(
- page.promise}
- recoveryRevealKey="session-recovery:failure-1"
- />,
- );
-
- expect(metrics.scrollTop).toBe(0);
-
- page.resolve(1);
- await act(async () => {
- await pendingLoad;
- });
- rerender(
- page.promise}
- recoveryRevealKey="session-recovery:failure-1"
- />,
- );
- expect(metrics.scrollTop).toBe(0);
-
- const nextPage = Promise.withResolvers();
- const nextLoadMore = sharedSentinelCalls.at(-1)?.[4] as () => Promise;
- const pendingNextLoad = nextLoadMore();
- rerender(
- nextPage.promise}
- recoveryRevealKey="session-recovery:failure-1"
- />,
- );
- act(() => {
- scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: 100 }));
- metrics.scrollTop = 40;
- scroller.dispatchEvent(new Event("scroll"));
- });
- expect(metrics.scrollTop).toBe(40);
-
- metrics.scrollHeight = 300;
- rerender(
- nextPage.promise}
- recoveryRevealKey="session-recovery:failure-1"
- />,
- );
- expect(metrics.scrollTop).toBe(140);
-
- nextPage.resolve(1);
- await act(async () => {
- await pendingNextLoad;
- });
- rerender(
- nextPage.promise}
- recoveryRevealKey="session-recovery:failure-1"
- />,
- );
- expect(metrics.scrollTop).toBe(140);
- });
-
- it("keeps an already-loading older page below a newly revealed recovery card", async () => {
- const page = Promise.withResolvers();
- const metrics = { scrollHeight: 100, scrollTop: 0, clientHeight: 50 };
- const activity = transcriptActivity(OLDEST_ACTIVITY_ID, "turn-1");
- const newest = transcriptMessage(NEWEST_MESSAGE_ID);
- const { rerender } = render(
- page.promise}
- />,
- );
- const wrappedLoadMore = sharedSentinelCalls.at(-1)?.[4] as () => Promise;
- const pendingLoad = wrappedLoadMore();
-
- rerender(
- page.promise}
- />,
- );
- rerender(
- page.promise}
- recoveryRevealKey="session-recovery:failure-2"
- />,
- );
- expect(metrics.scrollTop).toBe(0);
-
- metrics.scrollHeight = 200;
- rerender(
- page.promise}
- recoveryRevealKey="session-recovery:failure-2"
- />,
- );
- expect(metrics.scrollTop).toBe(0);
-
- page.resolve(1);
- await act(async () => {
- await pendingLoad;
- });
- rerender(
- page.promise}
- recoveryRevealKey="session-recovery:failure-2"
- />,
- );
- expect(metrics.scrollTop).toBe(0);
- });
-
- it("resumes normal auto-scroll after the initial recovery placement is consumed", () => {
- const metrics = { scrollHeight: 100, scrollTop: 0, clientHeight: 50 };
- const firstMessage = { id: "first-message" } as Message;
- const scrollerMessages = [firstMessage];
- const activity = transcriptActivity(OLDEST_ACTIVITY_ID, "turn-1");
- const newest = transcriptMessage(NEWEST_MESSAGE_ID);
- const { rerender } = render(
- ,
- );
- const scroller = screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID);
-
- metrics.scrollTop = 50;
- act(() => {
- scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: 100 }));
- scroller.dispatchEvent(new Event("scroll"));
- });
- metrics.scrollHeight = 150;
- rerender(
- ,
- );
-
- expect(metrics.scrollTop).toBe(2_147_483_647);
- });
-
// @covers AC-UI-TRANSCRIPT-AUTO-SCROLL-001.11
// @covers AC-UI-TRANSCRIPT-AUTO-SCROLL-001.14
it("places cached enabled history before refresh and reconciles after it settles", () => {
@@ -1049,48 +743,6 @@ describe("useNativeScrollManagement transcript pagination", () => {
expect(sharedSentinelRecheck).not.toHaveBeenCalled();
});
- it("does not recheck a restored transcript while explicit recovery is active", () => {
- const requestAnimationFrame = vi
- .spyOn(window, "requestAnimationFrame")
- .mockImplementation((callback) => {
- callback(0);
- return 1;
- });
- const recoveryRef = { current: false };
- const { rerender } = render(
- ,
- );
- const options = sharedSentinelCalls.at(-1)?.[5] as {
- onLoadSettled: (result: {
- count: number;
- rejected: boolean;
- continuation: "no-progress";
- }) => void;
- };
- act(() => {
- options.onLoadSettled({ count: 0, rejected: false, continuation: "no-progress" });
- });
- sharedSentinelRecheck.mockClear();
-
- rerender(
- ,
- );
-
- expect(recoveryRef.current).toBe(true);
- expect(sharedSentinelRecheck).not.toHaveBeenCalled();
- requestAnimationFrame.mockRestore();
- });
-
it("retries a disarmed short page on the next upward scroll", () => {
const metrics = { scrollHeight: 1000, scrollTop: 100, clientHeight: 400 };
render();
@@ -1351,18 +1003,10 @@ describe("useNativeScrollManagement transcript pagination", () => {
// eslint-disable-next-line max-lines-per-function -- this suite keeps the related scroll invariants together.
describe("useScrollToDividerOrBottom — anchored-bar offset", () => {
- it("leaves an active recovery transcript at the top for its first reveal", () => {
- render(
- ,
- );
+ it("places an enabled transcript at the bottom on its first render", () => {
+ render();
- expect(screen.getByTestId(DIVIDER_SCROLL_CONTAINER_TEST_ID).scrollTop).toBe(0);
+ expect(screen.getByTestId(DIVIDER_SCROLL_CONTAINER_TEST_ID).scrollTop).toBe(1000);
});
it("waits for an inactive transcript to become visible before placing the initial view", () => {
diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx
index fce6222c59..fe4f4c357f 100644
--- a/apps/web/components/task/chat/message-list-native.tsx
+++ b/apps/web/components/task/chat/message-list-native.tsx
@@ -2,15 +2,7 @@
/* eslint-disable max-lines -- native transcript composition owns scrolling. */
-import {
- useEffect,
- useMemo,
- useRef,
- memo,
- forwardRef,
- useImperativeHandle,
- type ReactNode,
-} from "react";
+import { useEffect, useMemo, useRef, memo, forwardRef, useImperativeHandle } from "react";
import { SessionPanelContent } from "@kandev/ui/pannel-session";
import type { Message, TaskSessionState } from "@/lib/types/http";
import type { RenderItem } from "@/hooks/use-processed-messages";
@@ -110,7 +102,6 @@ type NativeMessageListScrollParams = {
/** Changes when transcript status rows can add/remove space above messages. */
scrollLayoutKey: string;
isVisible: boolean;
- recoveryRevealKey?: string | null;
};
type ScrollToDividerOptions = {
@@ -121,22 +112,8 @@ type ScrollToDividerOptions = {
isProgrammaticScrollLocked?: () => boolean;
isVisible?: boolean;
historyRefreshPending?: boolean;
- recoveryRevealKey?: string | null;
};
-function consumeRecoveryRevealForDivider(
- recoveryRevealKey: string | null,
- didInitialScroll: React.RefObject,
- activationPendingRef: React.RefObject,
-): boolean {
- if (recoveryRevealKey === null) return false;
- // The native initial-position hook owns the recovery reveal. Mark this
- // hook consumed so its normal bottom/divider placement cannot override it.
- didInitialScroll.current = true;
- activationPendingRef.current = false;
- return true;
-}
-
function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
const {
scrollRef,
@@ -159,7 +136,6 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
onFirstMessageHiddenChange,
scrollLayoutKey,
isVisible,
- recoveryRevealKey,
} = params;
const {
handleScrollToMessage,
@@ -182,7 +158,6 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
isLoadingMore,
loadMore,
isVisible,
- recoveryRevealKey,
});
const anchoredBarOffsetPx = anchoredBarScrollOffsetPx(anchoredBarHeight);
useEffect(() => {
@@ -196,7 +171,6 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
isProgrammaticScrollLocked,
isVisible,
historyRefreshPending,
- recoveryRevealKey,
});
useImperativeHandle(ref, () => ({ scrollToMessage: handleScrollToMessage }), [
handleScrollToMessage,
@@ -304,7 +278,6 @@ type NativeMessageListBodyProps = {
onScrollToMessage: (messageId: string, options?: { align?: "start" | "center" }) => void;
autoScrollEnabled: boolean;
dividerBeforeItemKey?: string | null;
- prependContent?: ReactNode;
launchErrorOwned: boolean;
launchErrorStamp?: string;
launchErrorOccurredAt?: string;
@@ -346,7 +319,7 @@ type NativeMessageListBodyProps = {
* keep their existing visible-host behavior without participating in read
* tracking.
*/
-// eslint-disable-next-line max-lines-per-function -- divider and recovery placement share one scroll lifecycle.
+// eslint-disable-next-line max-lines-per-function -- divider and initial placement share one scroll lifecycle.
export function useScrollToDividerOrBottom(
scrollRef: React.RefObject,
itemCount: number,
@@ -362,7 +335,6 @@ export function useScrollToDividerOrBottom(
isProgrammaticScrollLocked = () => false,
isVisible = true,
historyRefreshPending = false,
- recoveryRevealKey = null,
} = options;
const { isVisibleRef, activationPendingRef } = useActivationPending(isVisible);
const isUserScrollingRef = useDividerUserScrolling(scrollRef);
@@ -393,8 +365,6 @@ export function useScrollToDividerOrBottom(
}
const el = scrollRef.current;
if (!el || historyRefreshPending) return;
- if (consumeRecoveryRevealForDivider(recoveryRevealKey, didInitialScroll, activationPendingRef))
- return;
if (itemCount === 0) return;
const placeInitialPosition = () => {
@@ -464,7 +434,6 @@ export function useScrollToDividerOrBottom(
isProgrammaticScrollLocked,
isVisible,
historyRefreshPending,
- recoveryRevealKey,
scrollRef,
]);
}
@@ -520,14 +489,12 @@ function NativeMessageListBody({
onScrollToMessage,
autoScrollEnabled,
dividerBeforeItemKey,
- prependContent,
launchErrorOwned,
launchErrorStamp,
launchErrorOccurredAt,
}: NativeMessageListBodyProps) {
return (
- {prependContent ?
{prependContent}
: null}
{/* Sentinel for lazy loading older messages */}
{hasMore &&
}
@@ -623,8 +590,6 @@ export const NativeMessageList = memo(
launchErrorOwned = false,
launchErrorStamp,
launchErrorOccurredAt,
- prependContent,
- recoveryRevealKey,
}: MessageListProps,
ref,
) {
@@ -699,7 +664,6 @@ export const NativeMessageList = memo(
isWorking,
].join(":"),
isVisible,
- recoveryRevealKey,
});
return (
@@ -739,7 +703,6 @@ export const NativeMessageList = memo(
onScrollToMessage={handleScrollToMessage}
autoScrollEnabled={autoScrollEnabled}
dividerBeforeItemKey={dividerBeforeItemKey}
- prependContent={prependContent}
launchErrorOwned={launchErrorOwned}
launchErrorStamp={launchErrorStamp}
launchErrorOccurredAt={launchErrorOccurredAt}
diff --git a/apps/web/components/task/chat/message-list-shared.tsx b/apps/web/components/task/chat/message-list-shared.tsx
index 7e7923e42c..c8e0efc829 100644
--- a/apps/web/components/task/chat/message-list-shared.tsx
+++ b/apps/web/components/task/chat/message-list-shared.tsx
@@ -64,10 +64,6 @@ export type MessageListProps = {
* top — the desktop-only, opt-in anchored last-prompt bar. `null`/`undefined`
* when the setting is off or on mobile. */
stickyPromptBar?: ReactNode;
- /** Rendered above transcript status and messages inside the native viewport. */
- prependContent?: ReactNode;
- /** Identity of the active recovery content for one-time initial reveal. */
- recoveryRevealKey?: string | null;
/** Current rendered height (px) of the anchored last-prompt bar's pinned
* overlay, or 0/undefined when it isn't showing. Lets a target scrolled
* to the top of the transcript (e.g. the unread "New" divider) reserve
diff --git a/apps/web/components/task/chat/messages/action-message-details.tsx b/apps/web/components/task/chat/messages/action-message-details.tsx
index d38317ba9f..19ad5f780d 100644
--- a/apps/web/components/task/chat/messages/action-message-details.tsx
+++ b/apps/web/components/task/chat/messages/action-message-details.tsx
@@ -11,6 +11,9 @@ export type ActionMeta = {
action_visibility?: "running";
variant?: string;
recovery_actions?: boolean;
+ scope?: "session" | "task";
+ error_stamp?: string;
+ recovery_stamp?: string;
is_auth_error?: boolean;
auth_methods?: RecoveryAuthMethod[];
error_output?: string;
diff --git a/apps/web/components/task/chat/messages/action-message-recovery.test.tsx b/apps/web/components/task/chat/messages/action-message-recovery.test.tsx
index 18aa55d7e8..688a78cc76 100644
--- a/apps/web/components/task/chat/messages/action-message-recovery.test.tsx
+++ b/apps/web/components/task/chat/messages/action-message-recovery.test.tsx
@@ -123,23 +123,25 @@ function renderWithTranscript(
});
}
-describe("ActionMessage — recovery card retires once the agent is back", () => {
- it("hides the card when a resume re-established the agent after the failure", () => {
+describe("ActionMessage — recovery history remains after the agent is back", () => {
+ it("retains the entry without controls when a resume re-established the agent", () => {
// A resumed agent settles at WAITING_FOR_INPUT, not RUNNING, so the session
// state alone never retires the card.
renderWithTranscript("WAITING_FOR_INPUT", [bootMessage(BOOTED_AFTER_FAILURE)]);
- expect(screen.queryByText(RECOVERY_MESSAGE)).toBeNull();
+ expect(screen.getByText(RECOVERY_MESSAGE)).toBeTruthy();
expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull();
expect(screen.queryByTestId(FRESH_TEST_ID)).toBeNull();
});
- it("hides the card when a fresh start re-established the agent after the failure", () => {
+ it("retains the entry without controls when a fresh start re-established the agent", () => {
renderWithTranscript("WAITING_FOR_INPUT", [
bootMessage(BOOTED_AFTER_FAILURE, { is_resuming: false }),
]);
+ expect(screen.getByText(RECOVERY_MESSAGE)).toBeTruthy();
expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull();
+ expect(screen.queryByTestId(FRESH_TEST_ID)).toBeNull();
});
it("keeps the card visible when the resume attempt failed", () => {
@@ -314,7 +316,7 @@ describe("ActionMessage — a recovery that failed keeps its controls", () => {
fireEvent.click(screen.getByTestId(RESUME_TEST_ID));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
- await waitFor(() => expect(screen.queryByText(RECOVERY_MESSAGE)).toBeNull());
+ await waitFor(() => expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull());
// The launch was accepted, so the card hid; the agent then failed to come up.
live.setSessionState("STARTING");
@@ -329,7 +331,7 @@ describe("ActionMessage — a recovery that failed keeps its controls", () => {
const live = renderWithLiveStore([]);
fireEvent.click(screen.getByTestId(RESUME_TEST_ID));
- await waitFor(() => expect(screen.queryByText(RECOVERY_MESSAGE)).toBeNull());
+ await waitFor(() => expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull());
live.setSessionState("FAILED");
@@ -341,13 +343,13 @@ describe("ActionMessage — a recovery that failed keeps its controls", () => {
const live = renderWithLiveStore([]);
fireEvent.click(screen.getByTestId(RESUME_TEST_ID));
- await waitFor(() => expect(screen.queryByText(RECOVERY_MESSAGE)).toBeNull());
+ await waitFor(() => expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull());
live.setSessionState("STARTING");
live.setMessages([bootMessage(BOOTED_AFTER_FAILURE)]);
live.setSessionState("WAITING_FOR_INPUT");
- expect(screen.queryByText(RECOVERY_MESSAGE)).toBeNull();
+ expect(screen.getByText(RECOVERY_MESSAGE)).toBeTruthy();
expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull();
});
});
diff --git a/apps/web/components/task/chat/messages/action-message.test.tsx b/apps/web/components/task/chat/messages/action-message.test.tsx
index ea8a12be4a..bc07cc33bd 100644
--- a/apps/web/components/task/chat/messages/action-message.test.tsx
+++ b/apps/web/components/task/chat/messages/action-message.test.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable max-lines -- recovery action variants share one rendering harness. */
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
@@ -142,12 +143,17 @@ function renderAction(
sessionState?: TaskSessionState,
sessionError?: string,
activeTurnId?: string,
+ sessionMetadata?: Record
| null,
) {
const initialState: Partial = sessionState
? {
taskSessions: {
items: {
- [TEST_SESSION_ID]: { state: sessionState, error_message: sessionError } as TaskSession,
+ [TEST_SESSION_ID]: {
+ state: sessionState,
+ error_message: sessionError,
+ metadata: sessionMetadata,
+ } as TaskSession,
},
},
turns: {
@@ -288,21 +294,68 @@ describe("ActionMessage — transient retry (warning variant)", () => {
renderAction(errorMsg, "WAITING_FOR_INPUT", "");
expect(screen.getByTestId(RESUME_TEST_ID)).toBeTruthy();
});
+});
- it("hides a recovery card after its Resume request succeeds", async () => {
+describe("ActionMessage — session recovery history", () => {
+ it("keeps the recovery entry after its Resume request succeeds and removes controls", async () => {
const errorMsg = recoveryMessage(true);
renderAction(errorMsg, "WAITING_FOR_INPUT", "");
fireEvent.click(screen.getByTestId(RESUME_TEST_ID));
- await waitFor(() => expect(screen.queryByText(RECOVERY_MESSAGE)).toBeNull());
+ await waitFor(() => expect(screen.getByText(RECOVERY_MESSAGE)).toBeTruthy());
+ expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull();
});
- it("keeps the recovery card hidden after a successful resume settles back to waiting", async () => {
+ it("shows recovery controls only for the current stamped failure", () => {
+ const historical = recoveryMessage(true);
+ historical.metadata = {
+ ...(historical.metadata as Record),
+ error_stamp: "failure-old",
+ };
+ const current = recoveryMessage(true);
+ current.metadata = {
+ ...(current.metadata as Record),
+ error_stamp: "failure-current",
+ };
+ const { rerender } = renderAction(historical, "WAITING_FOR_INPUT", "", undefined, {
+ last_agent_error: {
+ message: "The newer session failure.",
+ stamp: "failure-current",
+ },
+ });
+
+ expect(screen.getByText(RECOVERY_MESSAGE)).toBeTruthy();
+ expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull();
+
+ rerender();
+
+ expect(screen.getByTestId(RESUME_TEST_ID)).toBeTruthy();
+ });
+
+ it("keeps controls for an unstamped legacy row matching the current failure", () => {
+ const legacy = recoveryMessage(true);
+ legacy.content = "Agent encountered an error: The agent could not start.";
+ legacy.metadata = {
+ ...(legacy.metadata as Record),
+ error_stamp: undefined,
+ };
+
+ renderAction(legacy, "WAITING_FOR_INPUT", "", undefined, {
+ last_agent_error: {
+ message: "The agent could not start.",
+ occurred_at: legacy.created_at,
+ },
+ });
+
+ expect(screen.getByTestId(RESUME_TEST_ID)).toBeTruthy();
+ });
+
+ it("keeps the historical recovery entry after a successful resume settles back to waiting", async () => {
const errorMsg = recoveryMessage(true);
const { setSessionState } = renderActionWithStore(errorMsg, "WAITING_FOR_INPUT", "");
fireEvent.click(screen.getByTestId(RESUME_TEST_ID));
- await waitFor(() => expect(screen.queryByText(RECOVERY_MESSAGE)).toBeNull());
+ await waitFor(() => expect(screen.getByText(RECOVERY_MESSAGE)).toBeTruthy());
// A successful resume drives the session through an active state (which
// hides the card via isSessionActive) and then back to WAITING_FOR_INPUT
@@ -312,7 +365,7 @@ describe("ActionMessage — transient retry (warning variant)", () => {
setSessionState("STARTING");
setSessionState("WAITING_FOR_INPUT");
- expect(screen.queryByText(RECOVERY_MESSAGE)).toBeNull();
+ expect(screen.getByText(RECOVERY_MESSAGE)).toBeTruthy();
expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull();
});
diff --git a/apps/web/components/task/chat/messages/action-message.tsx b/apps/web/components/task/chat/messages/action-message.tsx
index a4e12fd124..bcb71d453a 100644
--- a/apps/web/components/task/chat/messages/action-message.tsx
+++ b/apps/web/components/task/chat/messages/action-message.tsx
@@ -13,11 +13,62 @@ import { parseRetryAt, retryCountdownLabel } from "./transient-retry";
import { hasSessionRecoveryResolutionAfter } from "@/hooks/processed-message-filtering";
import { ActionButtons } from "./action-message-actions";
import { SessionRecoveryActionButtons, sessionRecoveryAction } from "./action-message-recovery";
+import {
+ lastAgentErrorStamp,
+ readLastAgentError,
+ type LastAgentError,
+} from "@/lib/session-last-agent-error";
+import { legacyRecoveryMessageMatchesError } from "@/lib/session-recovery-presentation";
function isSessionActive(state?: TaskSessionState) {
return state === "RUNNING" || state === "STARTING" || state === "COMPLETED";
}
+function currentSessionRecoveryError(sessionMetadata: Record | null) {
+ if (!sessionMetadata) return null;
+ return readLastAgentError(sessionMetadata);
+}
+
+function isCurrentRecoveryMessage(
+ isRecoveryMessage: boolean,
+ messageRecoveryStamp: string | undefined,
+ currentRecoveryError: LastAgentError | null,
+ comment: Message,
+) {
+ if (!isRecoveryMessage) return true;
+ if (!currentRecoveryError) return true;
+ const currentRecoveryStamp = lastAgentErrorStamp(currentRecoveryError);
+ if (messageRecoveryStamp) return currentRecoveryStamp === messageRecoveryStamp;
+ return legacyRecoveryMessageMatchesError(
+ comment.content,
+ comment.created_at,
+ currentRecoveryError,
+ );
+}
+
+function shouldShowRecoveryActions({
+ isRecoveryMessage,
+ isCurrentRecovery,
+ recoveryResolvedDurably,
+ agentRebooted,
+ recoveryRequested,
+ recoveryFailedAgain,
+ sessionState,
+}: {
+ isRecoveryMessage: boolean;
+ isCurrentRecovery: boolean;
+ recoveryResolvedDurably: boolean;
+ agentRebooted: boolean;
+ recoveryRequested: boolean;
+ recoveryFailedAgain: boolean;
+ sessionState?: TaskSessionState;
+}) {
+ if (!isRecoveryMessage) return true;
+ if (!isCurrentRecovery || recoveryResolvedDurably || agentRebooted) return false;
+ if (recoveryRequested && !recoveryFailedAgain) return false;
+ return !isSessionActive(sessionState);
+}
+
export const ActionMessage = memo(function ActionMessage({ comment }: { comment: Message }) {
// Read session state from the store instead of receiving it as a prop, so a
// state transition doesn't re-render every message in the list (only the
@@ -51,6 +102,23 @@ export const ActionMessage = memo(function ActionMessage({ comment }: { comment:
// that came back failed — a failed boot row, or a session driven to FAILED —
// must surface its card again, buttons included, or the retry is unreachable.
const recoveryFailedAgain = agentBootFailed || sessionState === "FAILED";
+ const currentRecoveryError = currentSessionRecoveryError(sessionMetadata ?? null);
+ const messageRecoveryStamp = metadata?.recovery_stamp ?? metadata?.error_stamp;
+ const isCurrentRecovery = isCurrentRecoveryMessage(
+ isRecoveryMessage,
+ messageRecoveryStamp,
+ currentRecoveryError,
+ comment,
+ );
+ const recoveryActionsVisible = shouldShowRecoveryActions({
+ isRecoveryMessage,
+ isCurrentRecovery,
+ recoveryResolvedDurably,
+ agentRebooted,
+ recoveryRequested,
+ recoveryFailedAgain,
+ sessionState,
+ });
if (metadata?.action_visibility === "running") {
if (sessionState === "RUNNING" && comment.turn_id && activeTurnId === comment.turn_id) {
@@ -77,9 +145,7 @@ export const ActionMessage = memo(function ActionMessage({ comment }: { comment:
sessionState={sessionState}
taskId={comment.task_id}
sessionId={comment.session_id}
- recoveryResolved={
- recoveryResolvedDurably || agentRebooted || (recoveryRequested && !recoveryFailedAgain)
- }
+ recoveryActionsVisible={recoveryActionsVisible}
onRecoveryRequested={() => setRecoveryRequested(true)}
/>
);
@@ -92,7 +158,7 @@ function SettledActionMessage({
sessionState,
taskId,
sessionId,
- recoveryResolved,
+ recoveryActionsVisible,
onRecoveryRequested,
}: {
metadata: ActionMeta | undefined;
@@ -101,12 +167,12 @@ function SettledActionMessage({
sessionState?: TaskSessionState;
taskId?: string;
sessionId?: string;
- recoveryResolved: boolean;
+ recoveryActionsVisible: boolean;
onRecoveryRequested: () => void;
}) {
// A retry card is persisted against the failed turn, so hide it while the
// replacement turn is starting or running to avoid showing stale progress.
- if (isSessionActive(sessionState)) return null;
+ if (isSessionActive(sessionState) && metadata?.recovery_actions !== true) return null;
if (metadata?.retrying) {
return ;
@@ -117,10 +183,9 @@ function SettledActionMessage({
metadata={metadata}
message={message}
sessionError={sessionError}
- sessionState={sessionState}
taskId={taskId}
sessionId={sessionId}
- recoveryResolved={recoveryResolved}
+ recoveryActionsVisible={recoveryActionsVisible}
onRecoveryRequested={onRecoveryRequested}
/>
);
@@ -130,32 +195,23 @@ function SettledFailureMessage({
metadata,
message,
sessionError,
- sessionState,
taskId,
sessionId,
- recoveryResolved,
+ recoveryActionsVisible,
onRecoveryRequested,
}: {
metadata: ActionMeta | undefined;
message: string;
sessionError?: string;
- sessionState?: TaskSessionState;
taskId?: string;
sessionId?: string;
- recoveryResolved: boolean;
+ recoveryActionsVisible: boolean;
onRecoveryRequested: () => void;
}) {
- // A waiting session can still need recovery, so only hide this persisted card
- // once its own recovery resolved: either the Resume click was acknowledged or
- // the transcript shows the agent booted again after this failure. A resumed
- // agent settles at WAITING_FOR_INPUT, which isSessionActive deliberately
- // excludes, so without that second signal the card outlives the failure it
- // describes.
- if (isSessionActive(sessionState) || (metadata?.recovery_actions && recoveryResolved))
- return null;
+ const renderedMetadata = recoveryActionsVisible ? metadata : withoutRecoveryActions(metadata);
const specialRecovery = renderSpecialRecovery({
- metadata,
+ metadata: renderedMetadata,
message,
sessionError,
taskId,
@@ -169,16 +225,16 @@ function SettledFailureMessage({
? "text-amber-600 dark:text-amber-400"
: "text-red-600 dark:text-red-400";
return (
-
+
{message}
-
+
{renderSettledActionButtons({
- actions: metadata?.actions,
+ actions: renderedMetadata?.actions,
taskId,
sessionId,
isRecoveryMessage: metadata?.recovery_actions === true,
@@ -190,6 +246,11 @@ function SettledFailureMessage({
);
}
+function withoutRecoveryActions(metadata: ActionMeta | undefined): ActionMeta | undefined {
+ if (!metadata) return undefined;
+ return { ...metadata, actions: undefined };
+}
+
function renderSettledActionButtons({
actions,
taskId,
diff --git a/apps/web/components/task/mobile/session-mobile-layout.test.tsx b/apps/web/components/task/mobile/session-mobile-layout.test.tsx
index 7ade6d4bf8..9d9e149cc7 100644
--- a/apps/web/components/task/mobile/session-mobile-layout.test.tsx
+++ b/apps/web/components/task/mobile/session-mobile-layout.test.tsx
@@ -74,6 +74,7 @@ vi.mock("../prompt-history-panel-content", () => ({
import {
MobilePanelArea,
+ mobilePanelTopNavHeight,
resolveMobilePluginPanel,
resolveMobileReviewSource,
terminalPaddingBottom,
@@ -327,6 +328,11 @@ describe("resolveMobilePluginPanel", () => {
});
describe("MobilePanelArea PR identity", () => {
+ it("removes nested top-bar padding when the outer task error reserves it", () => {
+ expect(mobilePanelTopNavHeight(false)).toBe("3.5rem");
+ expect(mobilePanelTopNavHeight(true)).toBe("0px");
+ });
+
it("remounts detail feedback when the user chooses another mixed-provider review", () => {
function MobileReviewHarness() {
const reviews: ReviewItemSummary[] = [
diff --git a/apps/web/components/task/mobile/session-mobile-layout.tsx b/apps/web/components/task/mobile/session-mobile-layout.tsx
index bb913d1460..7541dae131 100644
--- a/apps/web/components/task/mobile/session-mobile-layout.tsx
+++ b/apps/web/components/task/mobile/session-mobile-layout.tsx
@@ -74,6 +74,10 @@ export function resolveMobileReviewSource(
const TOP_NAV_HEIGHT = "3.5rem";
const BOTTOM_NAV_HEIGHT = "3.25rem";
+export function mobilePanelTopNavHeight(hasSharedTaskError: boolean): string {
+ return hasSharedTaskError ? "0px" : TOP_NAV_HEIGHT;
+}
+
type SessionMobileLayoutProps = {
workspaceId: string | null;
workflowId: string | null;
@@ -94,6 +98,7 @@ type SessionMobileLayoutProps = {
onTaskUnarchived?: (taskId: string) => void;
taskCanvases?: Canvas[];
onOpenCanvas?: (canvasId: string) => void;
+ hasSharedTaskError?: boolean;
};
function MobileChatPanelContent({
@@ -213,7 +218,7 @@ export function MobilePanelArea({
style={{
paddingTop: `calc(${topNavHeight} + env(safe-area-inset-top, 0px))`,
paddingBottom: `calc(${bottomNavHeight} + env(safe-area-inset-bottom, 0px))`,
- height: "100dvh",
+ height: "100%",
}}
>
{currentMobilePanel === "chat" && (
@@ -662,7 +667,7 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
}
}, [currentMobilePanel, effectiveMobilePanel]);
return (
-
+
({
mocks.taskLaunchErrorProviderValue = value;
return <>{children}>;
},
+ useTaskLaunchErrorContext: () => null,
}));
vi.mock("@/hooks/use-task-sessions", () => ({
useTaskSessions: mocks.useTaskSessions,
@@ -371,7 +372,7 @@ describe("PreviewSessionBody delivery", () => {
expect(mocks.taskLaunchErrorProviderValue?.automaticRecovery).toBe(recovery);
});
- it("keeps the bootstrap recovery card and automatic outcome in passthrough preview", () => {
+ it("keeps passthrough recovery feedback outside the terminal preview", () => {
mocks.sessions = [
makeSession("session-1", {
is_passthrough: true,
@@ -410,9 +411,7 @@ describe("PreviewSessionBody delivery", () => {
render();
- expect(screen.getByTestId("preview-bootstrap-recovery-card").textContent).toContain(
- "recovery_failed",
- );
+ expect(screen.queryByTestId("preview-bootstrap-recovery-card")).toBeNull();
expect(screen.getByTestId("preview-passthrough-toolbar")).toBeTruthy();
});
});
diff --git a/apps/web/components/task/preview-session-tabs.tsx b/apps/web/components/task/preview-session-tabs.tsx
index aaf05494f6..e48c0b2e9f 100644
--- a/apps/web/components/task/preview-session-tabs.tsx
+++ b/apps/web/components/task/preview-session-tabs.tsx
@@ -25,11 +25,8 @@ import { TabRenameInput } from "./tab-rename-input";
import { PreviewPlanPanel, usePreviewPlanSummary } from "./preview-plan-panel";
import { TaskChatPanel } from "./task-chat-panel";
import { TaskLaunchErrorProvider } from "./task-launch-error-context";
-import {
- selectSessionRecoveryError,
- type SessionRecoveryOwner,
-} from "@/lib/session-recovery-presentation";
-import { SessionBootstrapRecoveryCard } from "@/components/task/chat/session-bootstrap-recovery-card";
+import { PreviewTaskErrorShell } from "./preview-task-error-shell";
+import { type SessionRecoveryOwner } from "@/lib/session-recovery-presentation";
import type { HandoffPreset } from "./new-session-dialog";
import { MAX_SESSION_NAME_LENGTH, useSessionRenameCommitter } from "./use-session-rename";
import {
@@ -253,30 +250,12 @@ function PreviewSessionTabDialogHost({
}
function PreviewSessionRecoverySurface({
- taskId,
- activeSession,
workspaceId,
- bootstrapRecoveryError,
resumption,
}: {
- taskId: string;
- activeSession: TaskSession | null;
workspaceId?: string | null;
- bootstrapRecoveryError: ReturnType;
resumption: ReturnType;
}) {
- if (activeSession?.is_passthrough && bootstrapRecoveryError) {
- return (
-
- );
- }
- if (bootstrapRecoveryError) return null;
return (
(() => [...sessionTabs, planTab], [sessionTabs, planTab]);
if (!isLoaded && sortedSessions.length === 0) {
- return ;
+ return (
+
+
+
+ );
}
if (hasNoSessions && !hasPlan) {
return (
-
+ statusSummary={taskStatusSummary}
+ resumption={resumption}
+ >
+
+
);
}
@@ -414,42 +403,43 @@ export function PreviewSessionTabs({
// still have a plan worth showing — fall back to the Plan view rather than
// an empty session body when there's nothing else to select.
return (
-
-
-
-
-
-
+
);
}
diff --git a/apps/web/components/task/preview-task-error-shell.tsx b/apps/web/components/task/preview-task-error-shell.tsx
new file mode 100644
index 0000000000..63d675ad5b
--- /dev/null
+++ b/apps/web/components/task/preview-task-error-shell.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { useSessionResumption } from "@/hooks/domains/session/use-session-resumption";
+import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary";
+import { TaskLaunchErrorProvider } from "./task-launch-error-context";
+import { TaskSharedError } from "./task-shared-error";
+
+export function PreviewTaskErrorShell({
+ taskId,
+ workspaceId,
+ statusSummary,
+ resumption,
+ children,
+}: {
+ taskId: string;
+ workspaceId?: string | null;
+ statusSummary?: ReturnType
;
+ resumption: ReturnType;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/web/components/task/simple/chat-activity-tabs.tsx b/apps/web/components/task/simple/chat-activity-tabs.tsx
index fcfa7a5560..13d5a3a52c 100644
--- a/apps/web/components/task/simple/chat-activity-tabs.tsx
+++ b/apps/web/components/task/simple/chat-activity-tabs.tsx
@@ -24,6 +24,8 @@ import type {
TimelineEvent,
} from "@/app/office/tasks/[id]/types";
import { useTranslation } from "react-i18next";
+import { TaskLaunchErrorProvider } from "@/components/task/task-launch-error-context";
+import { TaskSharedError } from "@/components/task/task-shared-error";
// TAB_TRIGGER_BASE adds a 1px ring to the active tab on top of
// shadcn's default bg/text active styling, so the selected tab is
@@ -137,44 +139,54 @@ export function ChatActivityTabs({
);
return (
-
-
-
- {t("task:chat")}
-
-
- {t("task:activity")}
-
+
+
+
+
+
+ {t("task:chat")}
+
+
+ {t("task:activity")}
+
+ {officeGroups.map((g) => (
+
+ ))}
+
+
+
+
+
+
+
+
{officeGroups.map((g) => (
-
+
))}
-
-
-
-
-
-
-
-
- {officeGroups.map((g) => (
-
- ))}
-
+
+
);
}
diff --git a/apps/web/components/task/simple/chat-entries.test.ts b/apps/web/components/task/simple/chat-entries.test.ts
index 70eb1f5db1..8bebab3230 100644
--- a/apps/web/components/task/simple/chat-entries.test.ts
+++ b/apps/web/components/task/simple/chat-entries.test.ts
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildRunErrorsFromSessions,
- filterVisibleRunErrors,
hasMatchingSessionLaunchError,
liveSessionMetadataFromStore,
mergeLiveSessionMetadata,
@@ -9,6 +8,7 @@ import {
import type { TaskSession } from "@/app/office/tasks/[id]/types";
const URL = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go";
+const SESSION_STARTED_AT = "2026-08-02T15:00:00Z";
function session(overrides: Partial): TaskSession {
return {
@@ -17,7 +17,7 @@ function session(overrides: Partial): TaskSession {
agentRole: "agent",
state: "FAILED",
isPrimary: false,
- startedAt: "2026-08-02T15:00:00Z",
+ startedAt: SESSION_STARTED_AT,
errorMessage: "usage limit reached",
...overrides,
};
@@ -64,12 +64,16 @@ describe("buildRunErrorsFromSessions", () => {
});
it("omits the URL when metadata or the field is absent", () => {
+ const legacyError = buildRunErrorsFromSessions([session({ errorMessage: "boom" })]);
+ expect(legacyError).toHaveLength(1);
+ expect(legacyError[0].remediationUrl).toBeUndefined();
+ expect(legacyError[0].isActive).toBe(true);
for (const s of [
- session({ errorMessage: "boom" }),
session({ metadata: { last_agent_error: { message: "boom" } } }),
session({ metadata: { last_agent_error: { message: "boom", remediation_url: "" } } }),
]) {
const errors = buildRunErrorsFromSessions([s]);
+ expect(errors).toHaveLength(1);
expect(errors[0].remediationUrl).toBeUndefined();
}
});
@@ -88,11 +92,38 @@ describe("buildRunErrorsFromSessions", () => {
expect(errors[0].remediationUrl).toBeUndefined();
});
- it("skips non-FAILED sessions", () => {
+ it("preserves an active error on a non-FAILED session", () => {
const errors = buildRunErrorsFromSessions([
- session({ state: "RUNNING", metadata: { last_agent_error: { remediation_url: URL } } }),
+ session({
+ state: "RUNNING",
+ metadata: { last_agent_error: { message: "still starting", stamp: "failure-1" } },
+ }),
]);
- expect(errors).toHaveLength(0);
+ expect(errors).toHaveLength(1);
+ expect(errors[0].isActive).toBe(true);
+ });
+
+ it("preserves a dismissed error as history after the session resumes", () => {
+ const errors = buildRunErrorsFromSessions([
+ session({
+ state: "IDLE",
+ metadata: {
+ last_agent_error: {
+ message: "The agent could not start.",
+ occurred_at: SESSION_STARTED_AT,
+ dismissed_at: "2026-08-02T15:05:00Z",
+ stamp: "failure-1",
+ recovery_actions: ["retry_default"],
+ },
+ },
+ }),
+ ]);
+
+ expect(errors).toHaveLength(1);
+ expect(errors[0].failedAt).toBe(SESSION_STARTED_AT);
+ expect(errors[0].errorStamp).toBe("failure-1");
+ expect(errors[0].isActive).toBe(false);
+ expect(errors[0].recoveryActions).toBeUndefined();
});
});
@@ -118,7 +149,7 @@ 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" },
+ { id: "e1", sessionId: "s1", rawPayload: "boom", failedAt: SESSION_STARTED_AT },
]),
).toBe(false);
});
@@ -130,7 +161,7 @@ describe("hasMatchingSessionLaunchError", () => {
id: "e1",
sessionId: "s1",
rawPayload: "boom",
- failedAt: "2026-08-02T15:00:00Z",
+ failedAt: SESSION_STARTED_AT,
errorStamp: "new-stamp",
},
]),
@@ -138,32 +169,6 @@ describe("hasMatchingSessionLaunchError", () => {
});
});
-describe("filterVisibleRunErrors", () => {
- it("keeps ordinary provider errors when the active summary is not a launch error", () => {
- const errors = buildRunErrorsFromSessions([
- session({
- metadata: {
- last_agent_error: {
- message: "The provider rejected the request.",
- code: "provider_auth_required",
- stamp: "provider-stamp-1",
- },
- },
- }),
- ]);
-
- expect(
- filterVisibleRunErrors(errors, {
- session_id: "s1",
- stamp: "provider-stamp-1",
- occurred_at: "2026-08-02T15:00:01Z",
- preview: "The provider rejected the request.",
- category: "provider_auth_required",
- }),
- ).toEqual(errors);
- });
-});
-
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 f9889a8a1a..338d7aeb64 100644
--- a/apps/web/components/task/simple/chat-entries.ts
+++ b/apps/web/components/task/simple/chat-entries.ts
@@ -1,8 +1,11 @@
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 { isTypedTaskLaunchError } from "./components/task-launch-error-entry";
+import {
+ lastAgentErrorStamp,
+ readLastAgentError,
+ readLastAgentErrorIncludingDismissed,
+} from "@/lib/session-last-agent-error";
import { isMatchingTaskLaunchError } from "@/components/task/chat/types";
import type {
RunError,
@@ -11,7 +14,6 @@ import type {
TaskSession,
TimelineEvent,
} from "@/app/office/tasks/[id]/types";
-import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
/**
* Discriminated union of every row that can appear in the task chat
@@ -50,38 +52,51 @@ export function buildLaterAgentReplyMap(comments: TaskComment[]): Map
- !isMatchingTaskLaunchError(activeError, {
- sessionId: error.sessionId,
- errorStamp: error.errorStamp,
- }),
- );
-}
-
export function mergeLiveSessionMetadata(
base: Record | null | undefined,
live: Record | null | undefined,
@@ -138,29 +139,6 @@ export function liveSessionMetadataFromStore(
return row.metadata;
}
-function remediationUrlFromSessionMetadata(metadata: Record | null | undefined) {
- const lastError = metadata?.last_agent_error;
- if (!lastError || typeof lastError !== "object") return undefined;
- const record = lastError as Record;
- const raw = record.remediation_url ?? record.remediationUrl;
- return typeof raw === "string" && raw !== "" ? raw : undefined;
-}
-
-function lastAgentErrorFromSessionMetadata(
- metadata: Record | null | undefined,
-): { code?: string; details?: string } | undefined {
- const lastError = metadata?.last_agent_error;
- if (!lastError || typeof lastError !== "object") return undefined;
- const record = lastError as Record;
- const code = record.code ?? record.failure_code ?? record.failureCode;
- const details =
- record.details ?? record.failure_details ?? record.failureDetails ?? record.error_output;
- return {
- code: typeof code === "string" && code !== "" ? code : undefined,
- details: typeof details === "string" && details !== "" ? details : undefined,
- };
-}
-
export type MergeChatEntriesArgs = {
comments: TaskComment[];
timeline: TimelineEvent[];
diff --git a/apps/web/components/task/simple/components/managed-runtime-npm-run-error.tsx b/apps/web/components/task/simple/components/managed-runtime-npm-run-error.tsx
index e77b13d22f..5ca3eb9476 100644
--- a/apps/web/components/task/simple/components/managed-runtime-npm-run-error.tsx
+++ b/apps/web/components/task/simple/components/managed-runtime-npm-run-error.tsx
@@ -16,7 +16,7 @@ export function ManagedRuntimeNpmRunError({
}: {
error: RunError;
agentName: string;
- onRetry: () => void;
+ onRetry?: () => void;
}) {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
@@ -56,18 +56,20 @@ export function ManagedRuntimeNpmRunError({
)}
-
-
-
+ {onRetry && (
+
+
+
+ )}
);
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
index 87d3a209bd..2bb375b65e 100644
--- a/apps/web/components/task/simple/components/run-error-entry.test.tsx
+++ b/apps/web/components/task/simple/components/run-error-entry.test.tsx
@@ -72,4 +72,17 @@ describe("RunErrorEntry", () => {
expect(screen.getByTestId("run-error-continue-new-branch-button")).toBeTruthy();
expect(screen.getByTestId("run-error-restore-workspace-button")).toBeTruthy();
});
+
+ it("renders recovered session history without stale recovery controls", () => {
+ render(
+
,
+ );
+
+ expect(screen.queryByTestId("run-error-resume-button")).toBeNull();
+ expect(screen.queryByTestId("run-error-fresh-button")).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 181dfe48bb..98436e534e 100644
--- a/apps/web/components/task/simple/components/run-error-entry.tsx
+++ b/apps/web/components/task/simple/components/run-error-entry.tsx
@@ -48,6 +48,7 @@ function TypedRunLaunchErrorEntry({
taskId={taskId}
workspaceId={workspaceId ?? ""}
repositories={repositories}
+ isActive={error.isActive !== false}
error={{
session_id: error.sessionId,
task_repository_id: error.taskRepositoryId,
@@ -125,9 +126,55 @@ function RunErrorRecoveryFeedback({
);
}
+function LegacyRunErrorActions({
+ error,
+ isActive,
+ busyAction,
+ onRecover,
+}: {
+ error: RunError;
+ isActive: boolean;
+ busyAction: SessionRecoveryAction | "restore" | null;
+ onRecover: (action: SessionRecoveryAction) => Promise
;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+ {isActive && (
+ <>
+
+
+ >
+ )}
+
+ );
+}
+
function LegacyRunErrorEntry({
agentName,
error,
+ isActive,
onRecover,
onRetry,
onRestore,
@@ -140,6 +187,7 @@ function LegacyRunErrorEntry({
}: {
agentName: string;
error: RunError;
+ isActive: boolean;
onRecover: (action: SessionRecoveryAction) => Promise;
onRetry: () => void;
onRestore: () => void;
@@ -168,16 +216,18 @@ function LegacyRunErrorEntry({
{t("task:theAgentStoppedWithAnError")}
-
+ {isActive && (
+
+ )}
{error.rawPayload && (
@@ -196,31 +246,12 @@ function LegacyRunErrorEntry({
)}
-
-
-
-
-
+
);
@@ -274,7 +305,7 @@ export function RunErrorEntry({
void handleRecover("runtime_retry")}
+ onRetry={error.isActive === false ? undefined : () => void handleRecover("runtime_retry")}
/>
);
}
@@ -283,6 +314,7 @@ export function RunErrorEntry({
void handleRestore()}
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
index 894c8cc0f6..43f44a5350 100644
--- 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
@@ -62,11 +62,14 @@ vi.mock("@/components/task/task-launch-error-context", () => ({
const TASK_ID = "task-1";
const TASK_REPOSITORY_ID = "task-repo-1";
+const SESSION_ID = "session-1";
const ERROR_STAMP = "launch-stamp-1";
const WORKSPACE_ID = "workspace-1";
const OCCURRED_AT = "2026-08-19T10:00:00Z";
const ERROR_ENTRY_TEST_ID = "task-launch-error-entry";
const RETRY_LAUNCH_BUTTON_TEST_ID = "task-launch-retry_launch-button";
+const RETRY_DEFAULT_BUTTON_TEST_ID = "task-launch-retry_default-button";
+const TASK_LAUNCH_RECOVER_METHOD = "task.launch.recover";
const error: TaskStatusSummaryActiveError = {
task_repository_id: TASK_REPOSITORY_ID,
@@ -118,7 +121,7 @@ describe("TaskLaunchErrorEntry", () => {
it("renders checkout details and retries the launch without branch settings", async () => {
const checkoutError: TaskStatusSummaryActiveError = {
- session_id: "session-1",
+ session_id: SESSION_ID,
stamp: "checkout-stamp-1",
occurred_at: OCCURRED_AT,
preview: "The workspace could not be prepared for this launch.",
@@ -138,9 +141,9 @@ describe("TaskLaunchErrorEntry", () => {
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
- expect(requestMock).toHaveBeenCalledWith("task.launch.recover", {
+ expect(requestMock).toHaveBeenCalledWith(TASK_LAUNCH_RECOVER_METHOD, {
task_id: TASK_ID,
- session_id: "session-1",
+ session_id: SESSION_ID,
action: "retry_launch",
error_stamp: "checkout-stamp-1",
});
@@ -205,7 +208,21 @@ describe("TaskLaunchErrorEntry", () => {
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.getByText(error.preview)).toBeTruthy();
- expect(screen.queryByTestId("task-launch-retry_default-button")).toBeNull();
+ expect(screen.queryByTestId(RETRY_DEFAULT_BUTTON_TEST_ID)).toBeNull();
+ });
+
+ it("hides recovery actions for a retained session history entry", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
+ expect(screen.queryByTestId(RETRY_DEFAULT_BUTTON_TEST_ID)).toBeNull();
});
it("routes a session-owned bootstrap error to the recovery card", () => {
@@ -213,13 +230,13 @@ describe("TaskLaunchErrorEntry", () => {
{
{
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.getByText(error.preview)).toBeTruthy();
- fireEvent.click(screen.getByTestId("task-launch-retry_default-button"));
+ fireEvent.click(screen.getByTestId(RETRY_DEFAULT_BUTTON_TEST_ID));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
- expect(requestMock).toHaveBeenCalledWith("task.launch.recover", {
+ expect(requestMock).toHaveBeenCalledWith(TASK_LAUNCH_RECOVER_METHOD, {
task_id: TASK_ID,
task_repository_id: TASK_REPOSITORY_ID,
action: "retry_default",
@@ -332,7 +349,7 @@ describe("TaskLaunchErrorEntry", () => {
fireEvent.click(screen.getByTestId("mock-branch-option"));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
- expect(requestMock).toHaveBeenCalledWith("task.launch.recover", {
+ expect(requestMock).toHaveBeenCalledWith(TASK_LAUNCH_RECOVER_METHOD, {
task_id: TASK_ID,
task_repository_id: TASK_REPOSITORY_ID,
action: "pick_base_branch",
@@ -341,6 +358,26 @@ describe("TaskLaunchErrorEntry", () => {
});
});
+ it("keeps task-scoped recovery on the task source when it has a session correlation", async () => {
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId(RETRY_DEFAULT_BUTTON_TEST_ID));
+ await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
+ expect(requestMock).toHaveBeenCalledWith(TASK_LAUNCH_RECOVER_METHOD, {
+ task_id: TASK_ID,
+ task_repository_id: TASK_REPOSITORY_ID,
+ action: "retry_default",
+ error_stamp: ERROR_STAMP,
+ });
+ });
+
it("waits for a branch selection before sending the picker action", async () => {
render(
{
>,
);
- const buttons = screen.getAllByTestId("task-launch-retry_default-button");
+ const buttons = screen.getAllByTestId(RETRY_DEFAULT_BUTTON_TEST_ID);
fireEvent.click(buttons[0]);
fireEvent.click(buttons[1]);
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
index 7e7616ab2c..c8b074616a 100644
--- a/apps/web/components/task/simple/components/task-launch-error-entry.tsx
+++ b/apps/web/components/task/simple/components/task-launch-error-entry.tsx
@@ -22,6 +22,8 @@ type TaskLaunchErrorEntryProps = {
workspaceId: string;
error: TaskStatusSummaryActiveError;
repositories?: TaskRepository[];
+ /** Retained session history has no live recovery controls. */
+ isActive?: boolean;
};
const pendingRecoveryRequests = new Map>();
@@ -58,7 +60,7 @@ function useTaskLaunchRecovery({
const request = Promise.resolve().then(() =>
client.request("task.launch.recover", {
task_id: taskId,
- ...(error.session_id ? { session_id: error.session_id } : {}),
+ ...(error.scope !== "task" && error.session_id ? { session_id: error.session_id } : {}),
...(error.task_repository_id ? { task_repository_id: error.task_repository_id } : {}),
action,
...(baseBranch ? { base_branch: baseBranch } : {}),
@@ -196,6 +198,7 @@ export function TaskLaunchErrorEntry({
workspaceId,
error,
repositories,
+ isActive = true,
}: TaskLaunchErrorEntryProps) {
const { t } = useTranslation();
const { pendingAction, recoveryError, sendRecovery } = useTaskLaunchRecovery({ taskId, error });
@@ -243,16 +246,18 @@ export function TaskLaunchErrorEntry({
)}
-
- {recoveryError && (
+ {isActive && (
+
+ )}
+ {isActive && recoveryError && (
{
expect(screen.queryByTestId("run-error-raw-payload")).toBeNull();
});
- it("renders a task-owned launch error before the empty state", () => {
+ it("leaves task-owned launch errors to the task shell", () => {
const statusSummary: TaskStatusSummary = {
revision: 3,
updated_at: T_11,
@@ -658,8 +658,8 @@ describe("TaskChat run error entries", () => {
/>,
),
);
- expect(screen.getByTestId("task-launch-error-entry")).toBeTruthy();
- expect(screen.getByText(/choose a base branch/i)).toBeTruthy();
+ expect(screen.queryByTestId("task-launch-error-entry")).toBeNull();
+ expect(screen.queryByText(/choose a base branch/i)).toBeNull();
expect(screen.getByText(/no comments yet/i)).toBeTruthy();
});
diff --git a/apps/web/components/task/simple/task-chat.tsx b/apps/web/components/task/simple/task-chat.tsx
index c7085edb1f..c1f196a801 100644
--- a/apps/web/components/task/simple/task-chat.tsx
+++ b/apps/web/components/task/simple/task-chat.tsx
@@ -21,7 +21,6 @@ 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";
@@ -35,7 +34,6 @@ import type {
import {
buildLaterAgentReplyMap,
buildRunErrorsFromSessions,
- filterVisibleRunErrors,
mergeChatEntries,
type ChatEntry,
} from "./chat-entries";
@@ -591,7 +589,6 @@ export function TaskChat({
onCommentsChanged,
taskTitle,
taskDescription,
- statusSummary,
repositories,
}: TaskChatProps) {
const { t } = useTranslation();
@@ -616,10 +613,6 @@ export function TaskChat({
);
const turnCtx = useMemo(() => buildCommentTurnContext(comments, sessions), [comments, sessions]);
const runErrors = useMemo(() => buildRunErrorsFromSessions(sessions), [sessions]);
- const visibleRunErrors = useMemo(
- () => filterVisibleRunErrors(runErrors, statusSummary?.active_error),
- [runErrors, statusSummary?.active_error],
- );
const laterAgentReplyMap = useMemo(() => buildLaterAgentReplyMap(comments), [comments]);
const entries = useMemo(
() =>
@@ -629,10 +622,10 @@ export function TaskChat({
groups: renderedGroups,
decisions,
turnCtx,
- runErrors: visibleRunErrors,
+ runErrors,
laterAgentReplyMap,
}),
- [comments, timeline, renderedGroups, decisions, turnCtx, visibleRunErrors, laterAgentReplyMap],
+ [comments, timeline, renderedGroups, decisions, turnCtx, runErrors, laterAgentReplyMap],
);
useChatAutoScroll(scrollParent ?? null, sessions, taskId);
@@ -653,12 +646,6 @@ export function TaskChat({
{t("task:showOlderSessions", { count: olderGroups.length })}
)}
-
{isEmpty ? (
{t("task:noCommentsYet")}
) : (
diff --git a/apps/web/components/task/task-chat-panel.launch-error.test.tsx b/apps/web/components/task/task-chat-panel.launch-error.test.tsx
index 76fcd1591c..5eb2b6aba3 100644
--- a/apps/web/components/task/task-chat-panel.launch-error.test.tsx
+++ b/apps/web/components/task/task-chat-panel.launch-error.test.tsx
@@ -2,13 +2,20 @@ import type { ReactNode } from "react";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Message } from "@/lib/types/http";
+import {
+ buildGroupedRenderItems,
+ insertLastAgentErrorItem,
+ type RenderItem,
+} from "@/hooks/use-processed-messages";
const launchError = {
stamp: "task-wide-launch-error",
+ scope: "task" as const,
occurred_at: "2026-08-20T10:00:00Z",
preview: "The workspace could not be prepared.",
category: "workspace_checkout_failed",
};
+const SESSION_ID = "prior-session";
const messageListRecoveryRevealKeys = vi.hoisted(() => [] as Array);
@@ -28,15 +35,15 @@ const appStoreState = {
},
taskSessions: {
items: {
- "prior-session": { name: null, agent_profile_id: null, last_read_message_id: null },
+ [SESSION_ID]: { name: null, agent_profile_id: null, last_read_message_id: null },
},
},
agentProfiles: { items: [] },
- messages: { bySession: { "prior-session": [priorTranscriptMessage] } },
+ messages: { bySession: { [SESSION_ID]: [priorTranscriptMessage] } },
};
const panelState = {
- resolvedSessionId: "prior-session",
+ resolvedSessionId: SESSION_ID,
session: {
state: "FAILED",
error_message: "The prior session stopped.",
@@ -47,7 +54,7 @@ const panelState = {
messagesLoading: false,
historyRefreshPending: false,
isInitialMessagesLoading: false,
- groupedItems: [],
+ groupedItems: [] as RenderItem[],
allMessages: [priorTranscriptMessage],
footerActionMessages: [],
permissionsByToolCallId: new Map(),
@@ -118,22 +125,37 @@ vi.mock("./chat/chat-input-area", () => ({
vi.mock("@/components/task/chat/message-list", () => ({
MessageList: ({
+ items,
messages,
- launchErrorOwned,
- prependContent,
recoveryRevealKey,
}: {
+ items: RenderItem[];
messages: Message[];
- launchErrorOwned?: boolean;
- prependContent?: ReactNode;
recoveryRevealKey?: string | null;
}) => {
messageListRecoveryRevealKeys.push(recoveryRevealKey);
return (
- {prependContent}
- {!launchErrorOwned &&
- messages.map((message) =>
{message.content}
)}
+ {messages.map((message) => {
+ const metadata = message.metadata as Record
| undefined;
+ return (
+
+ {message.content}
+
+ );
+ })}
+ {items.map((item) =>
+ item.type === "agent_error_notice" ? (
+
+ provisional recovery notice
+
+ ) : null,
+ )}
);
},
@@ -218,18 +240,16 @@ afterEach(() => {
});
describe("TaskChatPanel launch-error ownership", () => {
- it("keeps a task-wide card and prior failed-session surfaces together", () => {
- render();
+ it("leaves the task-wide card to the task shell and preserves session history", () => {
+ render();
- expect(screen.getByTestId("task-launch-error-entry").textContent).toContain(
- launchError.preview,
- );
- expect(screen.getByTestId("session-stopped-banner")).toBeTruthy();
+ expect(screen.queryByTestId("task-launch-error-entry")).toBeNull();
+ expect(screen.queryByTestId("session-stopped-banner")).toBeNull();
expect(screen.getByText(priorTranscriptMessage.content)).toBeTruthy();
- expect(messageListRecoveryRevealKeys.at(-1)).toBe("prior-session:task-wide-launch-error");
+ expect(messageListRecoveryRevealKeys.at(-1)).toBeUndefined();
});
- it("reveals a persisted bootstrap fallback for the selected session", () => {
+ it("does not reveal a persisted session failure through scroll coupling", () => {
const previousMetadata = panelState.session.metadata;
panelState.session.metadata = {
last_agent_error: {
@@ -241,11 +261,49 @@ describe("TaskChatPanel launch-error ownership", () => {
};
try {
- render();
+ render();
- expect(messageListRecoveryRevealKeys.at(-1)).toBe("prior-session:persisted-bootstrap-error");
+ expect(messageListRecoveryRevealKeys.at(-1)).toBeUndefined();
} finally {
panelState.session.metadata = previousMetadata;
}
});
+
+ it("counts one recovery representation across persisted messages and provisional notices", () => {
+ const persistedRecoveryMessage = {
+ ...priorTranscriptMessage,
+ id: "persisted-recovery",
+ session_id: SESSION_ID as Message["session_id"],
+ content: "Agent encountered an error: saved session failed.",
+ metadata: { recovery_actions: true, error_stamp: "session-failure-1" },
+ created_at: "2026-08-20T11:00:00Z",
+ } as Message;
+ const previousMessages = panelState.allMessages;
+ const previousItems = panelState.groupedItems;
+ panelState.allMessages = [priorTranscriptMessage, persistedRecoveryMessage];
+ panelState.groupedItems = insertLastAgentErrorItem(
+ buildGroupedRenderItems(panelState.allMessages, SESSION_ID, {
+ canAnchorPrepareProgress: false,
+ }),
+ SESSION_ID,
+ {
+ message: "saved session failed",
+ occurredAt: "2026-08-20T11:00:00Z",
+ stamp: "session-failure-1",
+ },
+ panelState.allMessages,
+ );
+
+ try {
+ render();
+
+ const persisted = screen.queryAllByTestId("persisted-recovery-message");
+ const provisional = screen.queryAllByTestId("agent-error-notice");
+ expect([...persisted, ...provisional]).toHaveLength(1);
+ expect(provisional).toHaveLength(0);
+ } finally {
+ panelState.allMessages = previousMessages;
+ panelState.groupedItems = previousItems;
+ }
+ });
});
diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx
index ca7988f60a..ed5e930311 100644
--- a/apps/web/components/task/task-chat-panel.tsx
+++ b/apps/web/components/task/task-chat-panel.tsx
@@ -52,16 +52,10 @@ import { routePanelMouseDown } from "./chat/route-panel-mouse-down";
import { useTranslation } from "react-i18next";
import { loadMessageWindowAround } from "@/hooks/domains/session/load-message-window";
-import { TaskChatLaunchError } from "./simple/components/task-chat-launch-error";
-import { isTypedTaskLaunchError } from "./simple/components/task-launch-error-entry";
import { useTaskLaunchErrorContext } from "./task-launch-error-context";
import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary";
-import {
- isTaskLaunchErrorOwnedBySession,
- isTaskLaunchErrorVisibleForSession,
-} from "@/components/task/chat/types";
import { TaskMarkdownFileLinkProvider } from "@/components/shared/task-markdown-file-link-provider";
-import { selectSessionRecoveryError } from "@/lib/session-recovery-presentation";
+import { statusSummaryTaskError } from "@/lib/task-status-summary";
/** Returns a `clarificationKey` that increments each time a pending
* clarification is resolved, letting the composer reset its input state for
@@ -1034,22 +1028,8 @@ export const TaskChatPanel = memo(function TaskChatPanel({
pendingClarification,
pendingClarificationGroup,
} = panelState;
- const activeLaunchError = launchStatusSummary?.active_error;
- const selectedSessionRecoveryError = selectSessionRecoveryError(
- activeLaunchError,
- resolvedSessionId,
- session?.metadata,
- );
- const visibleRecoveryError =
- selectedSessionRecoveryError ??
- (isTypedTaskLaunchError(activeLaunchError) &&
- isTaskLaunchErrorVisibleForSession(activeLaunchError, resolvedSessionId)
- ? activeLaunchError
- : null);
- const launchErrorOwned = Boolean(
- isTypedTaskLaunchError(activeLaunchError) &&
- isTaskLaunchErrorOwnedBySession(activeLaunchError, resolvedSessionId),
- );
+ const taskLaunchError = statusSummaryTaskError(launchStatusSummary);
+ const launchErrorOwned = Boolean(taskLaunchError);
const showAgentStartHint = useComposerAgentStartHint(
resolvedSessionId,
session?.state,
@@ -1174,20 +1154,6 @@ export const TaskChatPanel = memo(function TaskChatPanel({
(e: React.MouseEvent) => routePanelMouseDown(e, panelRef),
[],
);
- const launchErrorContent = launchErrorContext ? (
-
- ) : null;
- const recoveryRevealKey = visibleRecoveryError
- ? `${resolvedSessionId ?? ""}:${visibleRecoveryError.stamp || visibleRecoveryError.occurred_at}`
- : null;
-
return (
({
toastMock: vi.fn(),
}));
+const { statusSummaryMock } = vi.hoisted(() => ({
+ statusSummaryMock: vi.fn(),
+}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: toastMock }),
}));
vi.mock("@/lib/i18n", () => ({ t: (key: string) => key }));
+vi.mock("@/hooks/domains/task/use-task-status-summary", () => ({
+ useTaskStatusSummary: statusSummaryMock,
+}));
const initialSummary: TaskStatusSummary = {
revision: 1,
@@ -46,6 +52,10 @@ afterEach(() => {
beforeEach(() => {
toastMock.mockReset();
+ statusSummaryMock.mockReset();
+ statusSummaryMock.mockImplementation(
+ (_taskId: string, detail: TaskStatusSummary | null | undefined) => detail,
+ );
});
describe("TaskLaunchErrorProvider", () => {
@@ -72,4 +82,20 @@ describe("TaskLaunchErrorProvider", () => {
expect(screen.getByTestId("summary-stamp").textContent).toBe("initial");
});
+
+ it("replaces the hydrated summary with a live task projection", () => {
+ const liveSummary: TaskStatusSummary = {
+ ...initialSummary,
+ active_error: {
+ ...initialSummary.active_error!,
+ stamp: "live",
+ },
+ };
+ statusSummaryMock.mockReturnValue(liveSummary);
+
+ renderProvider(initialSummary);
+
+ expect(screen.getByTestId("summary-stamp").textContent).toBe("live");
+ expect(statusSummaryMock).toHaveBeenCalledWith("task-1", initialSummary);
+ });
});
diff --git a/apps/web/components/task/task-launch-error-context.tsx b/apps/web/components/task/task-launch-error-context.tsx
index 0574f0d518..49d1b7e15d 100644
--- a/apps/web/components/task/task-launch-error-context.tsx
+++ b/apps/web/components/task/task-launch-error-context.tsx
@@ -1,6 +1,6 @@
"use client";
-import { createContext, useContext, useMemo, type ReactNode } from "react";
+import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
import type { TaskRepository } from "@/lib/types/http";
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
import type { SessionRecoveryOwner } from "@/lib/session-recovery-presentation";
@@ -12,6 +12,8 @@ export type TaskLaunchErrorContextValue = {
statusSummary?: TaskStatusSummary | null;
repositories?: TaskRepository[];
automaticRecovery?: SessionRecoveryOwner | null;
+ /** Claims one assertive announcement for a task error stamp. */
+ claimTaskErrorAnnouncement?: (stamp: string) => boolean;
};
const TaskLaunchErrorContext = createContext(null);
@@ -24,8 +26,21 @@ export function TaskLaunchErrorProvider({
children: ReactNode;
}) {
const statusSummary = useTaskStatusSummary(value.taskId, value.statusSummary);
+ const announcedTaskErrorRef = useRef(null);
+ const claimTaskErrorAnnouncement = useCallback(
+ (stamp: string) => {
+ const key = `${value.taskId}:${stamp}`;
+ if (!stamp || announcedTaskErrorRef.current === key) return false;
+ announcedTaskErrorRef.current = key;
+ return true;
+ },
+ [value.taskId],
+ );
- const contextValue = useMemo(() => ({ ...value, statusSummary }), [statusSummary, value]);
+ const contextValue = useMemo(
+ () => ({ ...value, statusSummary, claimTaskErrorAnnouncement }),
+ [claimTaskErrorAnnouncement, statusSummary, value],
+ );
return (
{children}
diff --git a/apps/web/components/task/task-layout.tsx b/apps/web/components/task/task-layout.tsx
index e578cebbb1..50feef888d 100644
--- a/apps/web/components/task/task-layout.tsx
+++ b/apps/web/components/task/task-layout.tsx
@@ -9,10 +9,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";
import { useTaskCanvasLifecycleActivation } from "./dockview-canvas-activation";
+import { statusSummaryTaskError } from "@/lib/task-status-summary";
+import { useTaskLaunchErrorContext } from "./task-launch-error-context";
// Re-export for backwards compatibility
export type { SelectedDiff } from "@/hooks/use-session-layout-state";
@@ -76,31 +75,14 @@ export const TaskLayout = memo(function TaskLayout({
taskCanvases = [],
}: TaskLayoutProps) {
const { isMobile, usesDesktopWorkbench, isFullDesktop } = useResponsiveBreakpoint();
+ const launchErrorContext = useTaskLaunchErrorContext();
+ const hasSharedTaskError = Boolean(statusSummaryTaskError(launchErrorContext?.statusSummary));
useTaskCanvasLifecycleActivation({ taskId, workspaceId, isMobile });
const router = useRouter();
const onOpenCanvas = useCallback(
(canvasId: string) => router.push(canvasHref(canvasId)),
[router],
);
- const launchErrorContext = useTaskLaunchErrorContext();
- const activeLaunchError = launchErrorContext?.statusSummary?.active_error;
-
- if (launchErrorContext && !sessionId && isTypedTaskLaunchError(activeLaunchError)) {
- return (
-
-
-
- );
- }
-
// Mobile layout
if (isMobile) {
return (
@@ -123,6 +105,7 @@ export const TaskLayout = memo(function TaskLayout({
onTaskUnarchived={onTaskUnarchived}
taskCanvases={taskCanvases}
onOpenCanvas={onOpenCanvas}
+ hasSharedTaskError={hasSharedTaskError}
/>
);
}
diff --git a/apps/web/components/task/task-page-inner.tsx b/apps/web/components/task/task-page-inner.tsx
index 839e7552f7..f7d699e018 100644
--- a/apps/web/components/task/task-page-inner.tsx
+++ b/apps/web/components/task/task-page-inner.tsx
@@ -29,6 +29,7 @@ import {
} from "@/components/task/task-launch-error-context";
import { SessionBootstrapRecoveryCard } from "@/components/task/chat/session-bootstrap-recovery-card";
import { selectSessionRecoveryError } from "@/lib/session-recovery-presentation";
+import { TaskSharedError } from "@/components/task/task-shared-error";
import {
buildDebugEntries,
buildArchivedValue,
@@ -430,7 +431,10 @@ export function TaskPageInner(props: TaskPageInnerProps) {
workspaceId={task?.workspace_id ?? null}
isPassthrough={sessionPanel.isSessionPassthrough}
/>
-
+
+
+
+
diff --git a/apps/web/components/task/task-shared-error.test.tsx b/apps/web/components/task/task-shared-error.test.tsx
new file mode 100644
index 0000000000..8b38275a82
--- /dev/null
+++ b/apps/web/components/task/task-shared-error.test.tsx
@@ -0,0 +1,113 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { ReactNode } from "react";
+import { TaskSharedError } from "./task-shared-error";
+
+const { context, state, taskPreview } = vi.hoisted(() => {
+ const taskPreview = "The task could not be prepared.";
+ const state = { isMobile: false };
+ const context = {
+ taskId: "task-1",
+ workspaceId: "workspace-1",
+ statusSummary: {
+ revision: 4,
+ updated_at: "2026-09-14T10:00:00Z",
+ task_error: {
+ scope: "task" as const,
+ stamp: "task-error-1",
+ occurred_at: "2026-09-14T09:59:00Z",
+ preview: taskPreview,
+ recovery_actions: ["retry_launch" as const],
+ },
+ },
+ repositories: [],
+ claimTaskErrorAnnouncement: vi.fn((_stamp: string) => true),
+ };
+ return { context, state, taskPreview };
+});
+
+vi.mock("@/components/task/task-launch-error-context", () => ({
+ useTaskLaunchErrorContext: () => context,
+}));
+
+vi.mock("@/hooks/use-responsive-breakpoint", () => ({
+ useResponsiveBreakpoint: () => ({ isMobile: state.isMobile }),
+}));
+
+vi.mock("./simple/components/task-launch-error-entry", () => ({
+ TaskLaunchErrorEntry: () =>
,
+}));
+
+vi.mock("@kandev/ui/dialog", () => ({
+ Dialog: ({ children, open }: { children: ReactNode; open?: boolean }) => (
+
{open ? children : null}
+ ),
+ DialogContent: ({ children }: { children: ReactNode }) =>
{children}
,
+ DialogDescription: ({ children }: { children: ReactNode }) =>
{children}
,
+ DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
,
+ DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
,
+}));
+
+vi.mock("@kandev/ui/drawer", () => ({
+ Drawer: ({ children, open }: { children: ReactNode; open?: boolean }) => (
+
{open ? children : null}
+ ),
+ DrawerContent: ({ children }: { children: ReactNode }) =>
{children}
,
+ DrawerDescription: ({ children }: { children: ReactNode }) =>
{children}
,
+ DrawerHeader: ({ children }: { children: ReactNode }) =>
{children}
,
+ DrawerTitle: ({ children }: { children: ReactNode }) =>
{children}
,
+}));
+
+describe("TaskSharedError", () => {
+ beforeEach(() => {
+ context.claimTaskErrorAnnouncement.mockReset();
+ context.claimTaskErrorAnnouncement.mockReturnValue(true);
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("keeps a task-owned error above task content and opens desktop details", () => {
+ state.isMobile = false;
+ render(
);
+
+ expect(screen.getByTestId("task-shared-error")).toBeTruthy();
+ expect(screen.queryByRole("alert")).toBeNull();
+ expect(screen.getByTestId("task-shared-error-announcement").textContent).toContain(taskPreview);
+ expect(screen.getByText(taskPreview)).toBeTruthy();
+ expect(screen.queryByTestId("task-shared-error-details-content")).toBeNull();
+
+ fireEvent.click(screen.getByTestId("task-shared-error-details"));
+ expect(screen.getByTestId("task-shared-error-details-content")).toBeTruthy();
+ });
+
+ it("uses the mobile drawer for the same task error", () => {
+ state.isMobile = true;
+ render(
);
+
+ expect(screen.getByTestId("task-shared-error").className).toContain(
+ "mt-[calc(3.5rem+env(safe-area-inset-top,0px))]",
+ );
+
+ fireEvent.click(screen.getByTestId("task-shared-error-details"));
+ expect(screen.getByTestId("task-shared-error-details-content")).toBeTruthy();
+ });
+
+ it("announces a new task error stamp once across component remounts", () => {
+ let announcedStamp: string | null = null;
+ context.claimTaskErrorAnnouncement.mockImplementation((stamp: string) => {
+ if (announcedStamp === stamp) return false;
+ announcedStamp = stamp;
+ return true;
+ });
+
+ const first = render(
);
+ expect(screen.getByTestId("task-shared-error-announcement").textContent).toContain(taskPreview);
+ first.unmount();
+
+ render(
);
+ expect(screen.getByTestId("task-shared-error-announcement").textContent).toBe("");
+ expect(context.claimTaskErrorAnnouncement).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/apps/web/components/task/task-shared-error.tsx b/apps/web/components/task/task-shared-error.tsx
new file mode 100644
index 0000000000..105ce3396e
--- /dev/null
+++ b/apps/web/components/task/task-shared-error.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { IconAlertTriangle } from "@tabler/icons-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@kandev/ui/dialog";
+import {
+ Drawer,
+ DrawerContent,
+ DrawerDescription,
+ DrawerHeader,
+ DrawerTitle,
+} from "@kandev/ui/drawer";
+import { Button } from "@kandev/ui/button";
+import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
+import { statusSummaryTaskError } from "@/lib/task-status-summary";
+import { cn } from "@/lib/utils";
+import { useTaskLaunchErrorContext } from "./task-launch-error-context";
+import { TaskLaunchErrorEntry } from "./simple/components/task-launch-error-entry";
+
+/** Persistent task-owned failure surface. It is mounted above the task tabs so
+ * session changes and Dockview panel changes cannot hide the recovery affordance. */
+export function TaskSharedError({
+ reserveMobileTopBar = false,
+}: {
+ reserveMobileTopBar?: boolean;
+} = {}) {
+ const context = useTaskLaunchErrorContext();
+ const error = statusSummaryTaskError(context?.statusSummary);
+ const [detailsOpen, setDetailsOpen] = useState(false);
+ const localAnnouncementStampRef = useRef
(null);
+ const { isMobile } = useResponsiveBreakpoint();
+ const { t } = useTranslation();
+ const launchNeedsAttention = t("task:launchNeedsAttention");
+ const errorStamp = error?.stamp ?? "";
+ const [announcement, setAnnouncement] = useState("");
+
+ useEffect(() => {
+ if (!context || !error || !errorStamp) return;
+ const shouldAnnounce = context.claimTaskErrorAnnouncement
+ ? context.claimTaskErrorAnnouncement(errorStamp)
+ : localAnnouncementStampRef.current !== errorStamp;
+ if (!shouldAnnounce) return;
+ localAnnouncementStampRef.current = errorStamp;
+ setAnnouncement(`${error.preview}. ${launchNeedsAttention}`);
+ }, [context, error, errorStamp, launchNeedsAttention]);
+
+ if (!context || !error) return null;
+
+ const details = (
+
+ );
+
+ return (
+ <>
+
+
+
+
{error.preview}
+
{launchNeedsAttention}
+
+
+
+
+ {announcement}
+
+
+ {isMobile ? (
+
+
+
+ {error.preview}
+ {launchNeedsAttention}
+
+
+ {details}
+
+
+
+ ) : (
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/e2e/tests/kubernetes/kubernetes-executor.spec.ts b/apps/web/e2e/tests/kubernetes/kubernetes-executor.spec.ts
index 0f8aae2404..1604f20116 100644
--- a/apps/web/e2e/tests/kubernetes/kubernetes-executor.spec.ts
+++ b/apps/web/e2e/tests/kubernetes/kubernetes-executor.spec.ts
@@ -63,11 +63,16 @@ async function assertUserVisibleFailure(page: Page, taskId: string, expected: Re
await page.goto(`/t/${taskId}`);
const session = new SessionPage(page);
await session.waitForLoad(30_000);
- const launchError = session.activeChat().getByTestId("task-launch-error-entry");
- await expect(launchError).toBeVisible({ timeout: 30_000 });
- await launchError.getByRole("button", { name: "Show details" }).click();
- const details = launchError.getByTestId("task-launch-error-details");
- await expect(details).toBeVisible();
+ const chat = session.activeChat();
+ const recoveryRows = chat.locator(
+ "[data-testid='session-recovery-action-message']:has([data-testid='recovery-resume-button'], [data-testid='recovery-fresh-button'], [data-testid='recovery-restart-button'])",
+ );
+ await expect(recoveryRows).toHaveCount(1, { timeout: 30_000 });
+ const recovery = recoveryRows.first();
+ await expect(recovery).toBeVisible();
+ const details = recovery.locator("details");
+ await expect(details).toHaveCount(1);
+ await details.locator("summary").click();
await expect(details).toContainText(expected);
}
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 8f298146bc..e7514f43b6 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
@@ -71,8 +71,10 @@ test.describe("mobile PR watcher missing branch", () => {
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
- const chat = session.activeChat();
- const recovery = chat.getByTestId("task-launch-error-entry");
+ const sharedError = testPage.getByTestId("task-shared-error");
+ await expect(sharedError).toBeVisible({ timeout: 30_000 });
+ await sharedError.getByTestId("task-shared-error-details").tap();
+ const recovery = testPage.getByTestId("task-launch-error-entry");
await expect(recovery).toHaveCount(1, { timeout: 30_000 });
await expect(recovery).toContainText("The workspace could not be prepared for this launch.");
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 63746028e6..5a1ea35e9c 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
@@ -92,9 +92,11 @@ test.describe("PR watcher missing branch", () => {
const session = new SessionPage(testPage);
await session.waitForLoad();
- // --- Assert the bounded launch-error card appears once in chat ---
- const chat = session.activeChat();
- const recovery = chat.getByTestId("task-launch-error-entry");
+ // --- Assert the task-owned error strip and its focused recovery panel ---
+ const sharedError = testPage.getByTestId("task-shared-error");
+ await expect(sharedError).toBeVisible({ timeout: 30_000 });
+ await sharedError.getByTestId("task-shared-error-details").click();
+ const recovery = testPage.getByTestId("task-launch-error-entry");
await expect(recovery).toHaveCount(1, { timeout: 30_000 });
await expect(recovery).toContainText("The workspace could not be prepared for this launch.");
await expect(recovery).not.toContainText(prBranch);
diff --git a/apps/web/e2e/tests/session/mobile-session-resume-recovery.spec.ts b/apps/web/e2e/tests/session/mobile-session-resume-recovery.spec.ts
index f278e16504..c2da7a3cbc 100644
--- a/apps/web/e2e/tests/session/mobile-session-resume-recovery.spec.ts
+++ b/apps/web/e2e/tests/session/mobile-session-resume-recovery.spec.ts
@@ -106,7 +106,7 @@ test.describe("mobile: delayed resume cancellation", () => {
test.describe("mobile: failed resume recovery", () => {
test.describe.configure({ retries: 1 });
- test("failed saved-session load returns a usable recovery card", async ({
+ test("failed saved-session load retains a usable recovery entry", async ({
testPage,
apiClient,
seedData,
@@ -133,14 +133,15 @@ test.describe("mobile: failed resume recovery", () => {
await fixture.session.recoveryResumeButton().tap();
await expect(fixture.session.recoveryResumeButton()).toBeVisible();
// The resume endpoint waits through provider startup before it returns;
- // keep the card usable throughout that failed-load path and ensure the
- // old permanently pending label never replaces the recovery action.
+ // keep the persisted recovery entry usable throughout that failed-load
+ // path and ensure the old permanently pending label never replaces it.
await expect(testPage.getByText(/Resume session requested/i)).toHaveCount(0, {
timeout: 15_000,
});
+ await expect(fixture.session.recoveryResumeButton()).toBeVisible({ timeout: 90_000 });
await expect(
fixture.session.activeChat().getByTestId("session-bootstrap-recovery-card"),
- ).toBeVisible({ timeout: 90_000 });
+ ).toHaveCount(0);
await assertNoDocumentHorizontalOverflow(testPage, "mobile failed resume recovery");
} finally {
await apiClient.deleteAgentProfile(profile.id, true).catch(() => undefined);
diff --git a/apps/web/e2e/tests/task/create-task-github-url.spec.ts b/apps/web/e2e/tests/task/create-task-github-url.spec.ts
index dbb23e05c3..38b12e5310 100644
--- a/apps/web/e2e/tests/task/create-task-github-url.spec.ts
+++ b/apps/web/e2e/tests/task/create-task-github-url.spec.ts
@@ -644,7 +644,10 @@ test.describe("Task creation from GitHub URL", () => {
const session = new SessionPage(testPage);
await session.waitForLoad();
- const launchError = session.activeChat().getByTestId("task-launch-error-entry");
+ const sharedError = testPage.getByTestId("task-shared-error");
+ await expect(sharedError).toBeVisible({ timeout: 30_000 });
+ await sharedError.getByTestId("task-shared-error-details").click();
+ const launchError = testPage.getByTestId("task-launch-error-entry");
await expect(launchError).toBeVisible({ timeout: 30_000 });
await expect(launchError).toContainText(/launch needs attention/i);
diff --git a/apps/web/e2e/tests/task/launch-failure-recovery.spec.ts b/apps/web/e2e/tests/task/launch-failure-recovery.spec.ts
index 4d89aa6794..512b6b3ab2 100644
--- a/apps/web/e2e/tests/task/launch-failure-recovery.spec.ts
+++ b/apps/web/e2e/tests/task/launch-failure-recovery.spec.ts
@@ -12,12 +12,14 @@ import { readTranscriptScrollState, setTranscriptScrollTop } from "../../helpers
import { SessionPage } from "../../pages/session-page";
type LaunchError = {
+ scope?: "session" | "task";
session_id?: string;
task_repository_id?: string;
stamp?: string;
category?: string;
recovery_actions?: string[];
preview?: string;
+ details?: string;
};
async function taskLaunchError(
@@ -27,7 +29,20 @@ async function taskLaunchError(
): 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;
+ const summary = task?.status_summary as
+ | {
+ task_error?: LaunchError | null;
+ active_error?: LaunchError | null;
+ }
+ | null
+ | undefined;
+ const legacyTaskError = summary?.active_error;
+ return (
+ summary?.task_error ??
+ (legacyTaskError && !legacyTaskError.session_id && legacyTaskError.scope !== "session"
+ ? legacyTaskError
+ : null)
+ );
}
async function waitForTaskLaunchError(
@@ -147,12 +162,17 @@ test.describe("task launch failure recovery", () => {
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
+ const sharedError = testPage.getByTestId("task-shared-error");
+ await expect(sharedError).toBeVisible({ timeout: 30_000 });
+ await expect(sharedError).toContainText("The linked pull request is already closed or merged.");
+ await testPage.getByTestId("task-shared-error-details").click();
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 expect(card).toHaveCount(1);
await testPage.reload();
await session.waitForLoad();
+ await expect(testPage.getByTestId("task-shared-error")).toBeVisible();
+ await testPage.getByTestId("task-shared-error-details").click();
await expect(testPage.getByTestId("task-launch-error-entry")).toHaveCount(1);
await testPage.getByTestId("task-launch-mark_review_done-button").click();
@@ -178,7 +198,6 @@ test.describe("task launch failure recovery", () => {
backend,
}, testInfo) => {
test.setTimeout(150_000);
-
const workflow = await apiClient.createWorkflow(
seedData.workspaceId,
`Missing base recovery ${Date.now()}`,
@@ -231,8 +250,11 @@ test.describe("task launch failure recovery", () => {
}),
).toHaveCount(0);
+ const sharedError = testPage.getByTestId("task-shared-error");
+ await expect(sharedError).toBeVisible({ timeout: 30_000 });
+ await testPage.getByTestId("task-shared-error-details").click();
const card = testPage.getByTestId("task-launch-error-entry");
- await expect(card).toHaveCount(1, { timeout: 30_000 });
+ await expect(card).toHaveCount(1);
await expect(card).toContainText("The selected base branch is not available.");
await expect(card).not.toContainText("branch-that-no-longer-exists");
await expect(testPage.getByTestId("last-agent-error-notice")).toHaveCount(0);
@@ -243,6 +265,8 @@ test.describe("task launch failure recovery", () => {
restoreSeedRepositoryOrigin(seedData);
await testPage.reload();
await session.waitForLoad();
+ await expect(testPage.getByTestId("task-shared-error")).toBeVisible();
+ await testPage.getByTestId("task-shared-error-details").click();
await expect(testPage.getByTestId("task-launch-error-entry")).toHaveCount(1);
await testPage.getByTestId("task-launch-pick_base_branch-button").click();
@@ -353,7 +377,7 @@ test.describe("task launch failure recovery", () => {
}
});
- test("renders one correlated bootstrap recovery card with safe details", async ({
+ test("retains session failure after automatic recovery and output", async ({
testPage,
apiClient,
seedData,
@@ -381,7 +405,8 @@ test.describe("task launch failure recovery", () => {
);
await apiClient.seedAgentMessages(task.session_id, 40, "bootstrap history");
- const occurredAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
+ const failureStamp = "bootstrap-presentation-e2e";
+ const failureCreatedAt = new Date().toISOString();
const longDetails = Array.from(
{ length: 12 },
(_, index) => `agent_bootstrap; diagnostic_line=${index + 1}; cause=permission_denied`,
@@ -393,14 +418,15 @@ test.describe("task launch failure recovery", () => {
metadata: {
last_agent_error: {
message: "The agent could not start.",
- occurred_at: occurredAt,
+ occurred_at: failureCreatedAt,
agent_execution_id: "bootstrap-execution-e2e",
execution_id: "bootstrap-execution-e2e",
phase: "bootstrap",
attempt_id: "bootstrap-execution-e2e",
code: "generic_launch_failure",
details: longDetails,
- stamp: "bootstrap-presentation-e2e",
+ scope: "session",
+ stamp: failureStamp,
causes: [
{
operation: "resume",
@@ -411,31 +437,67 @@ test.describe("task launch failure recovery", () => {
},
},
});
+ await apiClient.seedSessionMessage(task.session_id, {
+ type: "status",
+ content: "Agent startup failed: The agent could not start.",
+ createdAt: failureCreatedAt,
+ metadata: {
+ recovery_actions: true,
+ scope: "session",
+ error_stamp: failureStamp,
+ error_output: longDetails,
+ actions: [
+ {
+ type: "ws_request",
+ label: "Resume session",
+ icon: "refresh",
+ test_id: "recovery-resume-button",
+ params: {
+ method: "session.recover",
+ payload: { task_id: task.id, session_id: task.session_id, action: "resume" },
+ },
+ },
+ {
+ type: "ws_request",
+ label: "Start fresh session",
+ icon: "player-play",
+ test_id: "recovery-fresh-button",
+ params: {
+ method: "session.recover",
+ payload: { task_id: task.id, session_id: task.session_id, action: "fresh_start" },
+ },
+ },
+ ],
+ },
+ });
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
- const card = testPage.getByTestId("session-bootstrap-recovery-card");
- await expect(card).toHaveCount(1, { timeout: 30_000 });
- await expect(testPage.getByTestId("task-launch-error-entry")).toHaveCount(0);
- await expect(testPage.getByTestId("session-recovery-error")).toHaveCount(0);
- await expect(card).toContainText("Session startup needs attention");
- await expect(card).toContainText("Required contribution access was denied.");
- await expect(card).not.toContainText("remote-secret");
+ const failureText = "Agent startup failed: The agent could not start.";
+ const failureRows = testPage.locator("[id^='msg-']").filter({ hasText: failureText });
+ await expect(failureRows).toHaveCount(1, { timeout: 30_000 });
+ const failureRow = failureRows.first();
+ await expect(failureRow).toContainText(failureText);
+ await expect(testPage.getByTestId("task-shared-error")).toHaveCount(0);
const transcript = session.activeChat().locator(".chat-message-list").first();
await expect
.poll(async () => (await readTranscriptScrollState(transcript)).scrollOwnerCount)
.toBe(1);
const initialScroll = await readTranscriptScrollState(transcript);
expect(initialScroll.scrollHeight).toBeGreaterThan(initialScroll.clientHeight);
- expect(initialScroll.scrollTop).toBe(0);
+ expect(initialScroll.scrollTop).toBeGreaterThan(0);
await expect(session.activeChat().getByTestId("chat-input-area")).toBeVisible();
- const details = card.getByTestId("session-bootstrap-recovery-details");
- await expect(details).not.toHaveAttribute("open");
- await details.getByText("Recovery details").click();
- await expect(details).toHaveAttribute("open", "");
- await expect(card).toContainText("The required contribution access was denied.");
+ const details = failureRow.getByText("Technical details", { exact: true });
+ await details.click();
+ const detailsPanel = failureRow.locator("details");
+ await expect(detailsPanel).toHaveAttribute("open", "");
+ await expect(detailsPanel).toContainText(
+ "agent_bootstrap; diagnostic_line=1; cause=permission_denied",
+ );
+ await expect(failureRow.getByTestId("recovery-resume-button")).toBeVisible();
+ await expect(failureRow.getByTestId("recovery-fresh-button")).toBeVisible();
const expandedScroll = await readTranscriptScrollState(transcript);
expect(expandedScroll.scrollOwnerCount).toBe(1);
expect(expandedScroll.scrollHeight).toBeGreaterThan(expandedScroll.clientHeight);
@@ -445,56 +507,103 @@ test.describe("task launch failure recovery", () => {
);
expect(middleScrollTop).toBeGreaterThan(0);
- const sameStampMetadata = {
- last_agent_error: {
- message: "The agent could not start.",
- occurred_at: occurredAt,
- agent_execution_id: "bootstrap-execution-e2e",
- execution_id: "bootstrap-execution-e2e",
- phase: "bootstrap",
- attempt_id: "bootstrap-execution-e2e",
- code: "generic_launch_failure",
- details: longDetails,
- stamp: "bootstrap-presentation-e2e",
- causes: [
- {
- operation: "resume",
- code: "permission_denied",
- detail: "The required contribution access was denied.",
- },
- ],
- },
- };
- await apiClient.seedTaskSession(task.id, {
- state: "WAITING_FOR_INPUT",
- sessionId: task.session_id,
- agentProfileId: seedData.agentProfileId,
- metadata: sameStampMetadata,
- });
- await expect
- .poll(async () => (await readTranscriptScrollState(transcript)).scrollTop)
- .toBeGreaterThanOrEqual(Math.max(0, middleScrollTop - 2));
-
await apiClient.seedTaskSession(task.id, {
state: "WAITING_FOR_INPUT",
sessionId: task.session_id,
agentProfileId: seedData.agentProfileId,
metadata: {
- ...sameStampMetadata,
last_agent_error: {
- ...sameStampMetadata.last_agent_error,
- stamp: "bootstrap-presentation-e2e-new",
+ message: "The agent could not start.",
+ occurred_at: failureCreatedAt,
+ agent_execution_id: "bootstrap-execution-e2e",
+ execution_id: "bootstrap-execution-e2e",
+ phase: "bootstrap",
+ attempt_id: "bootstrap-execution-e2e",
+ code: "generic_launch_failure",
+ details: longDetails,
+ scope: "session",
+ stamp: failureStamp,
+ dismissed_at: new Date(Date.now() + 1_000).toISOString(),
},
},
});
- await expect.poll(async () => (await readTranscriptScrollState(transcript)).scrollTop).toBe(0);
- for (const testId of [
- "recovery-resume-button",
- "recovery-restore-workspace-button",
- "recovery-fresh-button",
- ]) {
- await expect(card.getByTestId(testId)).toBeVisible();
- }
+ await apiClient.seedSessionMessage(task.session_id, {
+ type: "script_execution",
+ content: "Agent resumed",
+ createdAt: new Date(Date.now() + 2_000).toISOString(),
+ metadata: {
+ script_type: "agent_boot",
+ is_resuming: true,
+ status: "exited",
+ exit_code: 0,
+ },
+ });
+ await apiClient.seedSessionMessage(task.session_id, {
+ type: "message",
+ content: "Recovered agent output",
+ createdAt: new Date(Date.now() + 3_000).toISOString(),
+ });
+ await expect(failureRow).toContainText(failureText);
+ await expect(failureRow.getByTestId("recovery-resume-button")).toHaveCount(0);
+ await expect
+ .poll(async () => (await readTranscriptScrollState(transcript)).scrollTop)
+ .toBeGreaterThanOrEqual(Math.max(0, middleScrollTop - 2));
+
+ const nextFailureStamp = "bootstrap-presentation-e2e-new";
+ const nextFailureCreatedAt = new Date(Date.now() + 4_000).toISOString();
+ await apiClient.seedTaskSession(task.id, {
+ state: "WAITING_FOR_INPUT",
+ sessionId: task.session_id,
+ agentProfileId: seedData.agentProfileId,
+ metadata: {
+ last_agent_error: {
+ message: "The agent could not start.",
+ occurred_at: nextFailureCreatedAt,
+ agent_execution_id: "bootstrap-execution-e2e-new",
+ execution_id: "bootstrap-execution-e2e-new",
+ phase: "bootstrap",
+ attempt_id: "bootstrap-execution-e2e-new",
+ code: "generic_launch_failure",
+ details: longDetails,
+ scope: "session",
+ stamp: nextFailureStamp,
+ },
+ },
+ });
+ await apiClient.seedSessionMessage(task.session_id, {
+ type: "status",
+ content: failureText,
+ createdAt: nextFailureCreatedAt,
+ metadata: {
+ recovery_actions: true,
+ scope: "session",
+ error_stamp: nextFailureStamp,
+ error_output: longDetails,
+ actions: [
+ {
+ type: "ws_request",
+ label: "Resume session",
+ icon: "refresh",
+ test_id: "recovery-resume-button",
+ params: {
+ method: "session.recover",
+ payload: { task_id: task.id, session_id: task.session_id, action: "resume" },
+ },
+ },
+ ],
+ },
+ });
+ await expect(failureRows).toHaveCount(2);
+ const currentFailureRow = failureRows.last();
+ await expect(currentFailureRow.getByTestId("recovery-resume-button")).toBeVisible();
+ await expect(failureRow.getByTestId("recovery-resume-button")).toHaveCount(0);
+
+ await testPage.reload();
+ await session.waitForLoad();
+ const reloadedFailureRows = testPage.locator("[id^='msg-']").filter({ hasText: failureText });
+ await expect(reloadedFailureRows).toHaveCount(2);
+ await expect(reloadedFailureRows.first().getByTestId("recovery-resume-button")).toHaveCount(0);
+ await expect(reloadedFailureRows.last().getByTestId("recovery-resume-button")).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage, "bootstrap recovery presentation");
await testPage.screenshot({
@@ -502,14 +611,8 @@ test.describe("task launch failure recovery", () => {
fullPage: true,
});
await prCapture.screenshot("bootstrap-recovery-card-desktop", {
- caption: "Desktop bootstrap recovery card with labeled recovery details and actions.",
+ caption: "Desktop transcript keeps recovered session failure history and current actions.",
fullPage: true,
});
- await testPage.reload();
- await session.waitForLoad();
- await expect(testPage.getByTestId("session-bootstrap-recovery-card")).toHaveCount(1, {
- timeout: 30_000,
- });
- await expect(testPage.getByTestId("session-recovery-error")).toHaveCount(0);
});
});
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
index a9f9230a97..e99cd7eebe 100644
--- a/apps/web/e2e/tests/task/mobile-launch-failure-recovery.spec.ts
+++ b/apps/web/e2e/tests/task/mobile-launch-failure-recovery.spec.ts
@@ -13,13 +13,21 @@ import { waitForSessionDone } from "../../helpers/session";
import { readTranscriptScrollState, setTranscriptScrollTop } from "../../helpers/transcript-scroll";
import { SessionPage } from "../../pages/session-page";
+async function taskLaunchError(apiClient: ApiClient, workspaceId: string, taskId: string) {
+ const { tasks } = await apiClient.listTasks(workspaceId);
+ const summary = tasks.find((candidate) => candidate.id === taskId)?.status_summary;
+ const legacy = summary?.active_error;
+ return (
+ summary?.task_error ??
+ (legacy && !legacy.session_id && legacy.scope !== "session" ? legacy : null)
+ );
+}
+
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;
+ const error = await taskLaunchError(apiClient, workspaceId, taskId);
return Boolean(error?.stamp && error.category === "base_branch_missing");
},
{ timeout: 60_000, message: "waiting for the mobile launch-error projection" },
@@ -81,8 +89,11 @@ test.describe("mobile task launch failure recovery", () => {
await session.waitForLoad();
await waitForLaunchError(apiClient, seedData.workspaceId, task.id);
+ const sharedError = testPage.getByTestId("task-shared-error");
+ await expect(sharedError).toBeVisible({ timeout: 30_000 });
+ await testPage.getByTestId("task-shared-error-details").tap();
const card = testPage.getByTestId("task-launch-error-entry");
- await expect(card).toHaveCount(1, { timeout: 30_000 });
+ await expect(card).toHaveCount(1);
await expect(testPage.getByTestId("last-agent-error-notice")).toHaveCount(0);
await expect(testPage.getByTestId("prepare-progress-panel")).toHaveCount(0);
await expect(testPage.getByTestId("missing-branch-recovery")).toHaveCount(0);
@@ -100,6 +111,8 @@ test.describe("mobile task launch failure recovery", () => {
restoreSeedRepositoryOrigin(seedData);
await testPage.reload();
await session.waitForLoad();
+ await expect(testPage.getByTestId("task-shared-error")).toBeVisible();
+ await testPage.getByTestId("task-shared-error-details").tap();
await testPage.getByTestId("task-launch-pick_base_branch-button").tap();
await expect(testPage.getByTestId("task-launch-branch-picker-mobile")).toBeVisible({
timeout: 30_000,
@@ -130,16 +143,10 @@ test.describe("mobile task launch failure 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" },
- )
+ .poll(() => taskLaunchError(apiClient, seedData.workspaceId, task.id), {
+ 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);
@@ -209,19 +216,10 @@ test.describe("mobile task launch failure recovery", () => {
.toBe(true);
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: 30_000,
- message: "waiting for the mobile local-base launch error to remain clear",
- },
- )
+ .poll(() => taskLaunchError(apiClient, seedData.workspaceId, task.id), {
+ timeout: 30_000,
+ message: "waiting for the mobile local-base launch error to remain clear",
+ })
.toBeNull();
await expect(testPage.getByTestId("task-launch-error-entry")).toHaveCount(0);
@@ -236,7 +234,7 @@ test.describe("mobile task launch failure recovery", () => {
}
});
- test("renders the bootstrap recovery card with stacked touch controls", async ({
+ test("retains session failure history with stacked touch controls", async ({
testPage,
apiClient,
seedData,
@@ -264,7 +262,8 @@ test.describe("mobile task launch failure recovery", () => {
);
await apiClient.seedAgentMessages(task.session_id, 40, "mobile bootstrap history");
- const occurredAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
+ const failureStamp = "mobile-bootstrap-presentation-e2e";
+ const failureCreatedAt = new Date().toISOString();
const longDetails = Array.from(
{ length: 12 },
(_, index) => `agent_bootstrap; diagnostic_line=${index + 1}; cause=permission_denied`,
@@ -276,14 +275,15 @@ test.describe("mobile task launch failure recovery", () => {
metadata: {
last_agent_error: {
message: "The agent could not start.",
- occurred_at: occurredAt,
+ occurred_at: failureCreatedAt,
agent_execution_id: "mobile-bootstrap-execution-e2e",
execution_id: "mobile-bootstrap-execution-e2e",
phase: "bootstrap",
attempt_id: "mobile-bootstrap-execution-e2e",
code: "generic_launch_failure",
details: longDetails,
- stamp: "mobile-bootstrap-presentation-e2e",
+ scope: "session",
+ stamp: failureStamp,
causes: [
{
operation: "resume",
@@ -294,29 +294,59 @@ test.describe("mobile task launch failure recovery", () => {
},
},
});
+ await apiClient.seedSessionMessage(task.session_id, {
+ type: "status",
+ content: "Agent startup failed: The agent could not start.",
+ createdAt: failureCreatedAt,
+ metadata: {
+ recovery_actions: true,
+ scope: "session",
+ error_stamp: failureStamp,
+ error_output: longDetails,
+ actions: [
+ {
+ type: "ws_request",
+ label: "Resume session",
+ icon: "refresh",
+ test_id: "recovery-resume-button",
+ params: {
+ method: "session.recover",
+ payload: { task_id: task.id, session_id: task.session_id, action: "resume" },
+ },
+ },
+ {
+ type: "ws_request",
+ label: "Start fresh session",
+ icon: "player-play",
+ test_id: "recovery-fresh-button",
+ params: {
+ method: "session.recover",
+ payload: { task_id: task.id, session_id: task.session_id, action: "fresh_start" },
+ },
+ },
+ ],
+ },
+ });
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
- const card = testPage.getByTestId("session-bootstrap-recovery-card");
- await expect(card).toHaveCount(1, { timeout: 30_000 });
- await expect(testPage.getByTestId("task-launch-error-entry")).toHaveCount(0);
- await expect(testPage.getByTestId("session-recovery-error")).toHaveCount(0);
+ const failureText = "Agent startup failed: The agent could not start.";
+ const failureRows = testPage.locator("[id^='msg-']").filter({ hasText: failureText });
+ await expect(failureRows).toHaveCount(1, { timeout: 30_000 });
+ const failureRow = failureRows.first();
+ await expect(testPage.getByTestId("task-shared-error")).toHaveCount(0);
const transcript = session.activeChat().locator(".chat-message-list").first();
await expect
.poll(async () => (await readTranscriptScrollState(transcript)).scrollOwnerCount)
.toBe(1);
const initialScroll = await readTranscriptScrollState(transcript);
expect(initialScroll.scrollHeight).toBeGreaterThan(initialScroll.clientHeight);
- expect(initialScroll.scrollTop).toBe(0);
+ expect(initialScroll.scrollTop).toBeGreaterThan(0);
await expect(session.activeChat().getByTestId("chat-input-area")).toBeVisible();
- for (const testId of [
- "recovery-resume-button",
- "recovery-restore-workspace-button",
- "recovery-fresh-button",
- ]) {
- const button = card.getByTestId(testId);
+ for (const testId of ["recovery-resume-button", "recovery-fresh-button"]) {
+ const button = failureRow.getByTestId(testId);
await expect(button).toBeVisible();
await expect(button).toBeInViewport();
const box = await button.boundingBox();
@@ -324,11 +354,13 @@ test.describe("mobile task launch failure recovery", () => {
expect(box!.height).toBeGreaterThanOrEqual(44);
}
- const details = card.getByTestId("session-bootstrap-recovery-details");
- await expect(details).not.toHaveAttribute("open");
- await details.getByText("Recovery details").tap();
- await expect(details).toHaveAttribute("open", "");
- await expect(card).toContainText("The required contribution access was denied.");
+ const details = failureRow.getByText("Technical details", { exact: true });
+ await details.tap();
+ const detailsPanel = failureRow.locator("details");
+ await expect(detailsPanel).toHaveAttribute("open", "");
+ await expect(detailsPanel).toContainText(
+ "agent_bootstrap; diagnostic_line=1; cause=permission_denied",
+ );
const expandedScroll = await readTranscriptScrollState(transcript);
expect(expandedScroll.scrollOwnerCount).toBe(1);
expect(expandedScroll.scrollHeight).toBeGreaterThan(expandedScroll.clientHeight);
@@ -338,49 +370,103 @@ test.describe("mobile task launch failure recovery", () => {
);
expect(middleScrollTop).toBeGreaterThan(0);
- const sameStampMetadata = {
- last_agent_error: {
- message: "The agent could not start.",
- occurred_at: occurredAt,
- agent_execution_id: "mobile-bootstrap-execution-e2e",
- execution_id: "mobile-bootstrap-execution-e2e",
- phase: "bootstrap",
- attempt_id: "mobile-bootstrap-execution-e2e",
- code: "generic_launch_failure",
- details: longDetails,
- stamp: "mobile-bootstrap-presentation-e2e",
- causes: [
- {
- operation: "resume",
- code: "permission_denied",
- detail: "The required contribution access was denied.",
- },
- ],
- },
- };
- await apiClient.seedTaskSession(task.id, {
- state: "WAITING_FOR_INPUT",
- sessionId: task.session_id,
- agentProfileId: seedData.agentProfileId,
- metadata: sameStampMetadata,
- });
- await expect
- .poll(async () => (await readTranscriptScrollState(transcript)).scrollTop)
- .toBeGreaterThanOrEqual(Math.max(0, middleScrollTop - 2));
-
await apiClient.seedTaskSession(task.id, {
state: "WAITING_FOR_INPUT",
sessionId: task.session_id,
agentProfileId: seedData.agentProfileId,
metadata: {
- ...sameStampMetadata,
last_agent_error: {
- ...sameStampMetadata.last_agent_error,
- stamp: "mobile-bootstrap-presentation-e2e-new",
+ message: "The agent could not start.",
+ occurred_at: failureCreatedAt,
+ agent_execution_id: "mobile-bootstrap-execution-e2e",
+ execution_id: "mobile-bootstrap-execution-e2e",
+ phase: "bootstrap",
+ attempt_id: "mobile-bootstrap-execution-e2e",
+ code: "generic_launch_failure",
+ details: longDetails,
+ scope: "session",
+ stamp: failureStamp,
+ dismissed_at: new Date(Date.now() + 1_000).toISOString(),
},
},
});
- await expect.poll(async () => (await readTranscriptScrollState(transcript)).scrollTop).toBe(0);
+ await apiClient.seedSessionMessage(task.session_id, {
+ type: "script_execution",
+ content: "Agent resumed",
+ createdAt: new Date(Date.now() + 2_000).toISOString(),
+ metadata: {
+ script_type: "agent_boot",
+ is_resuming: true,
+ status: "exited",
+ exit_code: 0,
+ },
+ });
+ await apiClient.seedSessionMessage(task.session_id, {
+ type: "message",
+ content: "Recovered agent output",
+ createdAt: new Date(Date.now() + 3_000).toISOString(),
+ });
+ await expect(failureRow).toContainText(failureText);
+ await expect(failureRow.getByTestId("recovery-resume-button")).toHaveCount(0);
+ await expect
+ .poll(async () => (await readTranscriptScrollState(transcript)).scrollTop)
+ .toBeGreaterThanOrEqual(Math.max(0, middleScrollTop - 2));
+
+ const nextFailureStamp = "mobile-bootstrap-presentation-e2e-new";
+ const nextFailureCreatedAt = new Date(Date.now() + 4_000).toISOString();
+ await apiClient.seedTaskSession(task.id, {
+ state: "WAITING_FOR_INPUT",
+ sessionId: task.session_id,
+ agentProfileId: seedData.agentProfileId,
+ metadata: {
+ last_agent_error: {
+ message: "The agent could not start.",
+ occurred_at: nextFailureCreatedAt,
+ agent_execution_id: "mobile-bootstrap-execution-e2e-new",
+ execution_id: "mobile-bootstrap-execution-e2e-new",
+ phase: "bootstrap",
+ attempt_id: "mobile-bootstrap-execution-e2e-new",
+ code: "generic_launch_failure",
+ details: longDetails,
+ scope: "session",
+ stamp: nextFailureStamp,
+ },
+ },
+ });
+ await apiClient.seedSessionMessage(task.session_id, {
+ type: "status",
+ content: failureText,
+ createdAt: nextFailureCreatedAt,
+ metadata: {
+ recovery_actions: true,
+ scope: "session",
+ error_stamp: nextFailureStamp,
+ error_output: longDetails,
+ actions: [
+ {
+ type: "ws_request",
+ label: "Resume session",
+ icon: "refresh",
+ test_id: "recovery-resume-button",
+ params: {
+ method: "session.recover",
+ payload: { task_id: task.id, session_id: task.session_id, action: "resume" },
+ },
+ },
+ ],
+ },
+ });
+ await expect(failureRows).toHaveCount(2);
+ const currentFailureRow = failureRows.last();
+ await expect(currentFailureRow.getByTestId("recovery-resume-button")).toBeVisible();
+ await expect(failureRow.getByTestId("recovery-resume-button")).toHaveCount(0);
+
+ await testPage.reload();
+ await session.waitForLoad();
+ const reloadedFailureRows = testPage.locator("[id^='msg-']").filter({ hasText: failureText });
+ await expect(reloadedFailureRows).toHaveCount(2);
+ await expect(reloadedFailureRows.first().getByTestId("recovery-resume-button")).toHaveCount(0);
+ await expect(reloadedFailureRows.last().getByTestId("recovery-resume-button")).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage, "mobile bootstrap recovery presentation");
await testPage.screenshot({
@@ -388,7 +474,7 @@ test.describe("mobile task launch failure recovery", () => {
fullPage: true,
});
await prCapture.screenshot("bootstrap-recovery-card-mobile", {
- caption: "Mobile bootstrap recovery card with stacked touch-sized actions.",
+ caption: "Mobile transcript keeps recovered session failure history and current actions.",
fullPage: true,
});
});
diff --git a/apps/web/hooks/processed-message-filtering.test.ts b/apps/web/hooks/processed-message-filtering.test.ts
index 5797b63fa5..87c8e59b5b 100644
--- a/apps/web/hooks/processed-message-filtering.test.ts
+++ b/apps/web/hooks/processed-message-filtering.test.ts
@@ -241,6 +241,53 @@ describe("filterVisibleMessages empty-turn notice supersession", () => {
});
});
+describe("filterVisibleMessages recovery history", () => {
+ const RECOVERY_MESSAGE = "Could not resume the saved session.";
+ const FIRST_RECOVERY_ID = "recovery-1";
+
+ it("keeps a session failure after later agent output arrives", () => {
+ const failure = baseMessage({
+ id: FIRST_RECOVERY_ID,
+ type: "status",
+ content: RECOVERY_MESSAGE,
+ metadata: { recovery_actions: true, recovery_stamp: "failure-1" },
+ });
+ const output = baseMessage({
+ id: "agent-1",
+ type: "message",
+ content: "The resumed session is ready.",
+ created_at: AFTER,
+ });
+
+ expect(
+ filterVisibleMessages([failure, output], new Set(), new Set()).map((message) => message.id),
+ ).toEqual([FIRST_RECOVERY_ID, "agent-1"]);
+ });
+
+ it("collapses duplicate deliveries of one failure while keeping later failures", () => {
+ const first = baseMessage({
+ id: FIRST_RECOVERY_ID,
+ type: "status",
+ content: RECOVERY_MESSAGE,
+ metadata: { recovery_actions: true, recovery_stamp: "failure-1" },
+ });
+ const duplicate = { ...first, id: `${FIRST_RECOVERY_ID}-duplicate` };
+ const second = baseMessage({
+ id: "recovery-2",
+ type: "status",
+ content: RECOVERY_MESSAGE,
+ metadata: { recovery_actions: true, recovery_stamp: "failure-2" },
+ created_at: AFTER,
+ });
+
+ expect(
+ filterVisibleMessages([first, duplicate, second], new Set(), new Set()).map(
+ (message) => message.id,
+ ),
+ ).toEqual([FIRST_RECOVERY_ID, "recovery-2"]);
+ });
+});
+
describe("hasFailedAgentBootAfter", () => {
it("returns true when a boot after the failure reports status failed", () => {
expect(hasFailedAgentBootAfter([bootMessage(AFTER, { status: "failed" })], ERROR_AT)).toBe(
diff --git a/apps/web/hooks/processed-message-filtering.ts b/apps/web/hooks/processed-message-filtering.ts
index d502c56ad8..fd07501a09 100644
--- a/apps/web/hooks/processed-message-filtering.ts
+++ b/apps/web/hooks/processed-message-filtering.ts
@@ -91,22 +91,23 @@ function isRecoveryMessage(message: Message): boolean {
return metadata?.recovery_actions === true;
}
-function deduplicateRecoveryMessages(messages: Message[]): Message[] {
- let lastRecoveryIndex = -1;
- for (let index = messages.length - 1; index >= 0; index--) {
- if (isRecoveryMessage(messages[index])) {
- lastRecoveryIndex = index;
- break;
- }
+function recoveryMessageStamp(message: Message): string | null {
+ const metadata = message.metadata as Record | undefined;
+ for (const key of ["recovery_stamp", "error_stamp", "failure_stamp"]) {
+ const value = metadata?.[key];
+ if (typeof value === "string" && value !== "") return value;
}
- if (lastRecoveryIndex === -1) return messages;
- const hasLaterActivity = messages
- .slice(lastRecoveryIndex + 1)
- .some((message) => message.type === "message" || message.type === "content");
- return messages.filter((message, index) => {
+ return null;
+}
+
+function deduplicateRecoveryMessages(messages: Message[]): Message[] {
+ const seenStamps = new Set();
+ return messages.filter((message) => {
if (!isRecoveryMessage(message)) return true;
- if (hasLaterActivity) return false;
- return index === lastRecoveryIndex;
+ const stamp = recoveryMessageStamp(message);
+ if (!stamp || seenStamps.has(stamp)) return !stamp;
+ seenStamps.add(stamp);
+ return true;
});
}
diff --git a/apps/web/hooks/use-processed-messages-legacy-recovery.test.ts b/apps/web/hooks/use-processed-messages-legacy-recovery.test.ts
new file mode 100644
index 0000000000..f2762ff127
--- /dev/null
+++ b/apps/web/hooks/use-processed-messages-legacy-recovery.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http";
+import { buildGroupedRenderItems, insertLastAgentErrorItem } from "./use-processed-messages";
+
+const ERROR_MESSAGE = "peer disconnected before response";
+const ERROR_OCCURRED_AT = "2026-06-14T10:05:00Z";
+
+function legacyRecoveryMessage(): Message {
+ return {
+ id: "legacy-recovery",
+ session_id: toSessionId("s1"),
+ task_id: toTaskId("t1"),
+ author_type: "agent",
+ content: `Agent startup failed: ${ERROR_MESSAGE}`,
+ type: "status",
+ metadata: { recovery_actions: true },
+ created_at: ERROR_OCCURRED_AT,
+ };
+}
+
+describe("legacy recovery message deduplication", () => {
+ it("uses a matching unstamped row instead of adding a provisional notice", () => {
+ const legacy = legacyRecoveryMessage();
+ const items = buildGroupedRenderItems([legacy], "s1");
+
+ const result = insertLastAgentErrorItem(
+ items,
+ "s1",
+ { message: ERROR_MESSAGE, occurredAt: ERROR_OCCURRED_AT },
+ [legacy],
+ );
+
+ expect(result).toBe(items);
+ });
+});
diff --git a/apps/web/hooks/use-processed-messages-recovery.test.ts b/apps/web/hooks/use-processed-messages-recovery.test.ts
new file mode 100644
index 0000000000..50da8b2820
--- /dev/null
+++ b/apps/web/hooks/use-processed-messages-recovery.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import {
+ sessionId as toSessionId,
+ taskId as toTaskId,
+ type Message,
+ type MessageType,
+} from "@/lib/types/http";
+import { buildGroupedRenderItems, insertLastAgentErrorItem } from "./use-processed-messages";
+
+const SESSION_ID = "s1";
+const ERROR_OCCURRED_AT = "2026-06-14T10:05:00Z";
+
+function makeMessage(
+ id: string,
+ type: MessageType,
+ metadata?: Record,
+ session = SESSION_ID,
+): Message {
+ return {
+ id,
+ session_id: toSessionId(session),
+ task_id: toTaskId("t1"),
+ author_type: "agent",
+ content: "The agent encountered an error.",
+ type,
+ metadata,
+ created_at: ERROR_OCCURRED_AT,
+ };
+}
+
+describe("insertLastAgentErrorItem recovery identity", () => {
+ it("keeps one representation when the persisted recovery message is present", () => {
+ const persisted = makeMessage("recovery-1", "status", {
+ recovery_actions: true,
+ error_stamp: "failure-1",
+ });
+ const items = buildGroupedRenderItems([persisted], SESSION_ID, {
+ canAnchorPrepareProgress: false,
+ });
+
+ const result = insertLastAgentErrorItem(
+ items,
+ SESSION_ID,
+ { message: persisted.content, occurredAt: ERROR_OCCURRED_AT, stamp: "failure-1" },
+ [persisted],
+ );
+
+ expect(result.map((item) => item.type)).toEqual(["message"]);
+ expect(result.filter((item) => item.type === "agent_error_notice")).toHaveLength(0);
+ });
+
+ it.each([
+ {
+ name: "a different failure stamp",
+ messageSession: SESSION_ID,
+ messageStamp: "failure-1",
+ errorStamp: "failure-2",
+ },
+ {
+ name: "a different session",
+ messageSession: "s2",
+ messageStamp: "failure-1",
+ errorStamp: "failure-1",
+ },
+ ])("keeps the provisional notice for $name", ({ messageSession, messageStamp, errorStamp }) => {
+ const persisted = makeMessage(
+ "recovery-1",
+ "status",
+ { recovery_actions: true, error_stamp: messageStamp },
+ messageSession,
+ );
+ const items = buildGroupedRenderItems([], SESSION_ID, {
+ canAnchorPrepareProgress: false,
+ });
+
+ const result = insertLastAgentErrorItem(
+ items,
+ SESSION_ID,
+ { message: "new failure", occurredAt: ERROR_OCCURRED_AT, stamp: errorStamp },
+ [persisted],
+ );
+
+ expect(result.map((item) => item.type)).toEqual(["agent_error_notice"]);
+ });
+});
diff --git a/apps/web/hooks/use-processed-messages.test.ts b/apps/web/hooks/use-processed-messages.test.ts
index aa7c119f14..96549f5098 100644
--- a/apps/web/hooks/use-processed-messages.test.ts
+++ b/apps/web/hooks/use-processed-messages.test.ts
@@ -23,6 +23,8 @@ import {
} from "./use-processed-messages";
const CURRENT_TURN_ID = "turn-current";
+const PROVISIONAL_ERROR_MESSAGE = "peer disconnected before response";
+const ERROR_OCCURRED_AT = "2026-06-14T10:05:00Z";
function makeMessage(
id: string,
@@ -416,8 +418,8 @@ describe("insertLastAgentErrorItem", () => {
});
const result = insertLastAgentErrorItem(items, "s1", {
- message: "peer disconnected before response",
- occurredAt: "2026-06-14T10:05:00Z",
+ message: PROVISIONAL_ERROR_MESSAGE,
+ occurredAt: ERROR_OCCURRED_AT,
});
expect(result.map((item) => item.type)).toEqual(["message", "agent_error_notice", "message"]);
@@ -425,8 +427,8 @@ describe("insertLastAgentErrorItem", () => {
it("uses the notice as the only item when there are no messages", () => {
const result = insertLastAgentErrorItem([], "s1", {
- message: "peer disconnected before response",
- occurredAt: "2026-06-14T10:05:00Z",
+ message: PROVISIONAL_ERROR_MESSAGE,
+ occurredAt: ERROR_OCCURRED_AT,
});
expect(result).toEqual([
@@ -444,8 +446,8 @@ describe("insertLastAgentErrorItem", () => {
});
const result = insertLastAgentErrorItem(items, "s1", {
- message: "peer disconnected before response",
- occurredAt: "2026-06-14T10:05:00Z",
+ message: PROVISIONAL_ERROR_MESSAGE,
+ occurredAt: ERROR_OCCURRED_AT,
});
expect(result.map((item) => item.type)).toEqual(["message", "agent_error_notice"]);
diff --git a/apps/web/hooks/use-processed-messages.ts b/apps/web/hooks/use-processed-messages.ts
index a82e05aef8..fe8d08cdc4 100644
--- a/apps/web/hooks/use-processed-messages.ts
+++ b/apps/web/hooks/use-processed-messages.ts
@@ -15,7 +15,8 @@ import {
type PendingClarificationScope,
} from "@/lib/utils/pending-clarification";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
-import type { LastAgentError } from "@/lib/session-last-agent-error";
+import { lastAgentErrorStamp, type LastAgentError } from "@/lib/session-last-agent-error";
+import { legacyRecoveryMessageMatchesError } from "@/lib/session-recovery-presentation";
import {
buildChildrenByParentToolCallId,
buildPermissionsByToolCallId,
@@ -364,6 +365,7 @@ function buildGroupedItemsForHook(args: {
}),
args.resolvedSessionId,
args.lastAgentError,
+ args.allSessionMessages,
);
}
@@ -394,8 +396,16 @@ export function insertLastAgentErrorItem(
items: RenderItem[],
resolvedSessionId: string | null,
error?: LastAgentError | null,
+ persistedMessages: Message[] = [],
): RenderItem[] {
if (!resolvedSessionId || !error) return items;
+ if (
+ persistedMessages.some((message) =>
+ isPersistedRecoveryForSession(message, resolvedSessionId, error),
+ )
+ ) {
+ return items;
+ }
const notice: AgentErrorNoticeItem = {
type: "agent_error_notice",
id: `last-agent-error-${resolvedSessionId}-${error.occurredAt ?? "unknown"}`,
@@ -406,6 +416,21 @@ export function insertLastAgentErrorItem(
return [...items.slice(0, insertAt), notice, ...items.slice(insertAt)];
}
+function isPersistedRecoveryForSession(
+ message: Message,
+ sessionId: string,
+ error: LastAgentError,
+): boolean {
+ if (message.session_id !== sessionId) return false;
+ const metadata = message.metadata as Record | undefined;
+ if (metadata?.recovery_actions !== true) return false;
+ const messageStamp = metadata.error_stamp ?? metadata.recovery_stamp ?? metadata.failure_stamp;
+ if (typeof messageStamp === "string" && messageStamp !== "") {
+ return messageStamp === lastAgentErrorStamp(error);
+ }
+ return legacyRecoveryMessageMatchesError(message.content, message.created_at, error);
+}
+
/** Builds the todo checklist from the latest persisted `todo`-type message,
* accepting string and `{ text, done }` entries. Total by construction:
* malformed metadata yields an empty list, never throws. */
diff --git a/apps/web/lib/session-last-agent-error.ts b/apps/web/lib/session-last-agent-error.ts
index ce97e1388e..0e8db07cd4 100644
--- a/apps/web/lib/session-last-agent-error.ts
+++ b/apps/web/lib/session-last-agent-error.ts
@@ -7,6 +7,7 @@ import type { AgentErrorCause } from "@/lib/types/task-status-summary";
export type LastAgentError = {
message: string;
+ scope?: "session" | "task";
occurredAt?: string;
agentExecutionId?: string;
executionId?: string;
@@ -57,6 +58,20 @@ export function setStoredAcknowledgedAgentErrors(map: Record): v
}
export function readLastAgentError(metadata: Record | null | undefined) {
+ return readLastAgentErrorValue(metadata, false);
+}
+
+/** Reads the durable error breadcrumb even after recovery retired its controls. */
+export function readLastAgentErrorIncludingDismissed(
+ metadata: Record | null | undefined,
+) {
+ return readLastAgentErrorValue(metadata, true);
+}
+
+function readLastAgentErrorValue(
+ metadata: Record | null | undefined,
+ includeDismissed: boolean,
+): LastAgentError | null {
if (!metadata) return null;
const raw = metadata.last_agent_error;
if (!raw || typeof raw !== "object") return null;
@@ -64,15 +79,36 @@ export function readLastAgentError(metadata: Record | null | un
const message = typeof record.message === "string" ? record.message : "";
if (!message) return null;
const dismissedAt = readFirstOptionalString(record, ["dismissed_at", "dismissedAt"]);
- if (dismissedAt) return null;
- return { message, ...readOptionalAgentErrorFields(record) };
+ if (dismissedAt && !includeDismissed) return null;
+ return {
+ message,
+ ...readOptionalAgentErrorFields(record),
+ ...(dismissedAt ? { dismissedAt } : {}),
+ };
}
function readOptionalAgentErrorFields(
record: Record,
): Omit {
- const result: Omit = {};
+ return {
+ ...readOptionalAgentErrorIdentity(record),
+ ...readOptionalAgentErrorRecovery(record),
+ ...readStructuredFailureMetadata(record),
+ };
+}
+
+function readOptionalAgentErrorIdentity(
+ record: Record,
+): Pick<
+ LastAgentError,
+ "occurredAt" | "scope" | "agentExecutionId" | "executionId" | "phase" | "attemptId"
+> {
+ const result: Pick<
+ LastAgentError,
+ "occurredAt" | "scope" | "agentExecutionId" | "executionId" | "phase" | "attemptId"
+ > = {};
const occurredAt = readFirstOptionalString(record, ["occurred_at", "occurredAt"]);
+ const scope = readFirstOptionalString(record, ["scope"]);
const agentExecutionId = readFirstOptionalString(record, [
"agent_execution_id",
"agentExecutionId",
@@ -80,6 +116,25 @@ function readOptionalAgentErrorFields(
const executionId = readFirstOptionalString(record, ["execution_id", "executionId"]);
const phase = readFirstOptionalString(record, ["phase"]);
const attemptId = readFirstOptionalString(record, ["attempt_id", "attemptId"]);
+ if (occurredAt) result.occurredAt = occurredAt;
+ if (scope === "session" || scope === "task") result.scope = scope;
+ if (agentExecutionId) result.agentExecutionId = agentExecutionId;
+ if (executionId) result.executionId = executionId;
+ if (phase) result.phase = phase;
+ if (attemptId) result.attemptId = attemptId;
+ return result;
+}
+
+function readOptionalAgentErrorRecovery(
+ record: Record,
+): Pick<
+ LastAgentError,
+ "causes" | "remediationUrl" | "recoveryActions" | "taskRepositoryId" | "stamp"
+> {
+ const result: Pick<
+ LastAgentError,
+ "causes" | "remediationUrl" | "recoveryActions" | "taskRepositoryId" | "stamp"
+ > = {};
const causes = readAgentErrorCauses(record.causes);
const remediationUrl = readFirstOptionalString(record, ["remediation_url", "remediationUrl"]);
const recoveryActions = normalizeTaskLaunchRecoveryActions(
@@ -90,19 +145,11 @@ function readOptionalAgentErrorFields(
"taskRepositoryId",
]);
const stamp = readFirstOptionalString(record, ["stamp"]);
- const structured = readStructuredFailureMetadata(record);
- if (occurredAt) result.occurredAt = occurredAt;
- if (agentExecutionId) result.agentExecutionId = agentExecutionId;
- if (executionId) result.executionId = executionId;
- if (phase) result.phase = phase;
- if (attemptId) result.attemptId = attemptId;
if (causes.length > 0) result.causes = causes;
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;
}
diff --git a/apps/web/lib/session-recovery-presentation.test.ts b/apps/web/lib/session-recovery-presentation.test.ts
index c48a26c19f..3719b6b9f5 100644
--- a/apps/web/lib/session-recovery-presentation.test.ts
+++ b/apps/web/lib/session-recovery-presentation.test.ts
@@ -41,6 +41,7 @@ describe("session recovery presentation", () => {
selectedSessionMetadata,
),
).toEqual({
+ scope: "session",
session_id: "session-1",
stamp: "bootstrap-session-1",
occurred_at: "2026-09-11T09:00:00Z",
diff --git a/apps/web/lib/session-recovery-presentation.ts b/apps/web/lib/session-recovery-presentation.ts
index ed401ae6c9..c0dd03986a 100644
--- a/apps/web/lib/session-recovery-presentation.ts
+++ b/apps/web/lib/session-recovery-presentation.ts
@@ -1,5 +1,6 @@
import type { TaskStatusSummaryActiveError } from "./types/task-status-summary";
import { lastAgentErrorStamp, readLastAgentError } from "./session-last-agent-error";
+import type { LastAgentError } from "./session-last-agent-error";
import type {
ResumptionState,
SessionRecoveryFailure,
@@ -33,6 +34,7 @@ function sessionMetadataRecoveryError(
if (!lastError || lastError.phase !== "bootstrap") return null;
const error: TaskStatusSummaryActiveError = {
+ scope: "session",
session_id: sessionId,
stamp: lastAgentErrorStamp(lastError),
occurred_at: lastError.occurredAt ?? "",
@@ -61,6 +63,7 @@ export function selectSessionRecoveryError(
const persistedError = sessionMetadataRecoveryError(sessionId, sessionMetadata);
if (persistedError) return persistedError;
if (!isBootstrapSessionRecoveryError(activeError) || !activeError) return null;
+ if (activeError.scope === "task") return null;
return activeError.session_id === sessionId ? activeError : null;
}
@@ -71,3 +74,32 @@ export function ownsSessionRecoveryChat(
): boolean {
return selectSessionRecoveryError(activeError, sessionId, sessionMetadata) !== null;
}
+
+/** Matches a pre-stamp recovery row to the active session error safely. */
+export function legacyRecoveryMessageMatchesError(
+ contentValue: string,
+ createdAt: string | undefined,
+ currentError: LastAgentError,
+): boolean {
+ const content = contentValue.trim();
+ const message = currentError.message.trim();
+ if (!content || !message) return false;
+
+ const contentMatches =
+ content === message ||
+ content === `Agent encountered an error: ${message}` ||
+ content === `Agent startup failed: ${message}` ||
+ ((content.startsWith("Agent encountered an error:") ||
+ content.startsWith("Agent startup failed:")) &&
+ content.endsWith(message));
+ if (!contentMatches) return false;
+
+ // Metadata is written before the transcript row. Exclude an older legacy
+ // row with the same text when both sides carry usable timestamps.
+ if (!currentError.occurredAt) return true;
+ const occurredAt = Date.parse(currentError.occurredAt);
+ const messageCreatedAt = Date.parse(createdAt ?? "");
+ return (
+ Number.isNaN(occurredAt) || Number.isNaN(messageCreatedAt) || messageCreatedAt >= occurredAt
+ );
+}
diff --git a/apps/web/lib/task-status-summary.test.ts b/apps/web/lib/task-status-summary.test.ts
index 1787728a0c..55d83338f1 100644
--- a/apps/web/lib/task-status-summary.test.ts
+++ b/apps/web/lib/task-status-summary.test.ts
@@ -4,6 +4,7 @@ import {
pickFreshestStatusSummary,
selectTaskStatusSummary,
statusSummaryActiveErrorPreview,
+ statusSummaryTaskError,
} from "./task-status-summary";
import type { TaskStatusSummary } from "./types/task-status-summary";
@@ -90,4 +91,22 @@ describe("selectTaskStatusSummary", () => {
};
expect(statusSummaryActiveErrorPreview(taskOwned, {}, {})).toBe("Choose a base branch");
});
+
+ it("selects the independent task error even when a session error is newer", () => {
+ const taskOwned = summary(3);
+ taskOwned.task_error = {
+ scope: "task",
+ stamp: "task-error-3",
+ occurred_at: "2026-08-19T10:00:00Z",
+ preview: "Choose a base branch",
+ };
+ taskOwned.active_error = {
+ scope: "session",
+ session_id: "session-1",
+ stamp: "session-error-4",
+ occurred_at: "2026-08-19T11:00:00Z",
+ preview: "The session failed",
+ };
+ expect(statusSummaryTaskError(taskOwned)).toBe(taskOwned.task_error);
+ });
});
diff --git a/apps/web/lib/task-status-summary.ts b/apps/web/lib/task-status-summary.ts
index 3aadff7716..f9af264a51 100644
--- a/apps/web/lib/task-status-summary.ts
+++ b/apps/web/lib/task-status-summary.ts
@@ -79,3 +79,14 @@ export function statusSummaryActiveErrorPreview(
}
return error.preview;
}
+
+/** Returns the task-owned error while preserving compatibility with older
+ * summaries that placed it in `active_error` without a session identity. */
+export function statusSummaryTaskError(
+ summary: TaskStatusSummary | null | undefined,
+): TaskStatusSummary["task_error"] {
+ if (summary?.task_error) return summary.task_error;
+ const legacy = summary?.active_error;
+ if (legacy && !legacy.session_id && legacy.scope !== "session") return legacy;
+ return null;
+}
diff --git a/apps/web/lib/types/task-status-summary.ts b/apps/web/lib/types/task-status-summary.ts
index 9b4243d234..548fd54d10 100644
--- a/apps/web/lib/types/task-status-summary.ts
+++ b/apps/web/lib/types/task-status-summary.ts
@@ -8,6 +8,7 @@ export type AgentErrorCause = {
};
export type TaskStatusSummaryActiveError = {
+ scope?: "session" | "task";
session_id?: string;
task_repository_id?: string;
stamp: string;
@@ -37,6 +38,8 @@ export type TaskStatusSummary = {
/** Number of prompts currently en-queued for the task (all sessions). */
queued_prompt_count?: number;
active_error?: TaskStatusSummaryActiveError | null;
+ /** Current task-owned failure, independent of the selected session. */
+ task_error?: TaskStatusSummaryActiveError | null;
git?: {
additions?: number;
deletions?: number;
diff --git a/docs/decisions/2026-09-14-error-scope-and-history.md b/docs/decisions/2026-09-14-error-scope-and-history.md
new file mode 100644
index 0000000000..fc04ad3cd6
--- /dev/null
+++ b/docs/decisions/2026-09-14-error-scope-and-history.md
@@ -0,0 +1,36 @@
+# ADR-2026-09-14-error-scope-and-history: Error scope and conversation history
+
+**Status:** accepted
+**Date:** 2026-09-14
+**Area:** frontend
+
+## Context
+
+The startup recovery card precedes the transcript history loader. Special scroll ownership tries to keep it visible during pagination.
+After user scrolling releases that ownership, another history page can move the card out of view.
+Existing recovery-message filters also remove errors after later messages. This loses useful conversation context after automatic recovery.
+Shared task errors can affect several sessions and non-chat views.
+
+## Decision
+
+Session failures are durable chronological chat entries. Successful recovery removes active controls, not the historical entry.
+The normal transcript scroll controller owns these entries. No error-specific reveal or top-pinning policy remains.
+Task and shared workspace failures appear once in the task shell, above tabs and outside their content scrollers.
+Error scope comes from the failure owner. An initiating session or the presence of a repository identifier does not determine scope.
+Shared task errors have an independent projection, so session failures cannot hide them.
+Existing recovery permissions, stamps, provider identity, and sanitized details remain authoritative.
+
+## Consequences
+
+Users can read the failure followed by resumed agent work. Session errors can scroll out of view as ordinary messages.
+A shared alert stays visible across tab switches. Its details use a desktop dialog or phone drawer to preserve content space.
+Failure identity and recovery eligibility must remain separate from historical message visibility.
+This decision applies within an affected task. It does not introduce an installation-wide alert center or workspace-wide broadcast system.
+
+## Alternatives Considered
+
+- A fixed session banner keeps errors visible but duplicates conversation history and consumes permanent chat space.
+- A special entry above all history requires competing scroll rules and does not preserve chronological context.
+- Removing errors after recovery hides why the conversation stopped and restarted.
+- A shared error inside Chat disappears on other tabs and misrepresents its scope.
+- Inferring scope from error text or counting failed sessions can misclassify unrelated failures.
diff --git a/docs/plans/contribution-resume-recovery/plan.md b/docs/plans/contribution-resume-recovery/plan.md
index e43619f057..f7e9e8e6bb 100644
--- a/docs/plans/contribution-resume-recovery/plan.md
+++ b/docs/plans/contribution-resume-recovery/plan.md
@@ -298,3 +298,9 @@ Verification passed:
copy and sanitized details; never infer a specific Git diagnosis from them.
- Exact contributor history and the actor who changed it were not established.
Neither is necessary to reproduce the product defects.
+
+## September 14 presentation successor
+
+The [error scope package](../error-scope-and-history/plan.md) supersedes the session card placement and removal behavior.
+Completed results here remain historical evidence. Provider recovery, timeout budgets, cancellation, and authorization remain unchanged.
+The successor owns chronological error retention, ordinary scroll behavior, and shared task alerts.
diff --git a/docs/plans/error-scope-and-history/plan.md b/docs/plans/error-scope-and-history/plan.md
new file mode 100644
index 0000000000..b4cc59b374
--- /dev/null
+++ b/docs/plans/error-scope-and-history/plan.md
@@ -0,0 +1,203 @@
+---
+created: 2026-09-14
+status: complete
+requirements:
+ - REQ-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006
+ - REQ-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002
+system_design:
+ - ../../specs/agents/system-design/session-recovery-failures.md
+ - ../../specs/tasks/system-design/task-launch-failure-recovery.md
+legacy_specs: []
+---
+
+# Implementation Plan: Error scope and history
+
+## Overview
+
+Retain session errors as chronological messages after recovery. Show shared task errors above all task tabs.
+Implement session history first, then the shared task surface, then cross-surface regression coverage and public guidance.
+All three sequential work orders are complete. This document records the implementation and verification results for the accepted design package.
+
+## Confirmed intent and ownership
+
+The user chose ordinary session error messages and rejected duplicate banners.
+The user requires shared task/workspace failures to remain visible across agent sessions and other tabs.
+Tasks own durable failure identity, history, and shared projections. Agents retain recovery eligibility and session presentation.
+The proposed shared position is below the task header and above tabs. It covers this task, not every task in the workspace.
+No material question blocks this design.
+
+## Evidence and scope
+
+Task ef406dc0-8d8b-41bf-b9f1-39ec4b026ff9 had a saved-session load failure after exactly two minutes on September 14.
+The diagnostic bundle showed ACP `session/load` returning `context deadline exceeded`. The underlying provider delay remains unproven.
+`task-chat-panel.tsx` passes the recovery card as `prependContent` before the history loader.
+`message-list-native-scroll.ts` releases recovery top ownership after user scrolling. Later history restores message position and moves the card away.
+`deduplicateRecoveryMessages` removes prior recovery messages after later conversation activity.
+`SettledActionMessage` hides its body during STARTING, RUNNING, and COMPLETED states.
+The projector retains a separate `taskError`, but the public aggregate can select a newer session error.
+
+In scope: durable session entries, resolved action state, ordinary scrolling, explicit failure scope, shared task projection, all-tab presentation, localization, and regression tests.
+Out of scope: provider timeout increases, provider hang diagnosis, automatic conversation replacement, a global alert center, and a multi-incident task queue.
+The generic timeout label defect remains a separate diagnostic finding. This package preserves existing sanitized cause semantics.
+
+## Technical approach
+
+Task 01 extends the existing fenced error admission and persisted message path.
+Keep one marker per session/stamp, preserve markers after resolution, and render recovery actions only on the current error. Bootstrap failures use the execution-fenced terminal commit and then create the same idempotent session message. If that accepted commit is followed by a transcript write failure, the executor retries the same message identity. Profile-specific pre-agent failures remain session-owned, while shared worktree failures use task scope, and Office sessions use the same history producer.
+Remove activity-based error filtering, active-session body hiding, and error-specific scroll placement across task Chat and Quick Chat.
+
+Task 02 adds explicit scope to normalized error metadata and exposes independent `TaskStatusSummary.task_error`.
+Project existing task metadata independently of session errors. Preserve aggregate `active_error` compatibility.
+Mount one shared task-shell alert with a desktop dialog or phone drawer. Retain existing action authorization and stamp guards. Read the live status summary after hydration, announce each task error stamp once, and remove duplicate mobile top padding while retaining the outer task-shell offset.
+
+Task 03 proves recovery followed by new messages, pagination, reload, tab switching, mixed scopes, and mobile geometry. Its composed panel assertions count persisted recovery rows and provisional notices together, and legacy unstamped rows retain controls only when they match the current session error.
+Update public recovery guidance only after the behavior exists.
+
+## ASCII UI preview
+
+UI-01: Desktop task, shared failure plus an independently recovered session error.
+
+```text
++--------------------------------------------------------+
+| Task title / workflow | fixed
+| ! Workspace preparation failed. [Recovery details] | shared
++--------------------------------------------------------+
+| Session A | Session B | Plan | Pull request | Files | tabs
++--------------------------------------------------------+
+| Earlier conversation ^ |
+| ! Session resume failed. 10:18 | |
+| Loading timed out. [Recovery details] | | scrolls
+| Recovered. | |
+| Agent: I resumed work... v |
++--------------------------------------------------------+
+| Message input | fixed
++--------------------------------------------------------+
+```
+
+UI-02: Phone task Chat, same two independent failures.
+
+```text
++--------------------------------+
+| < Task title Session A v | fixed task chrome
+| ! Workspace preparation failed |
+| [Recovery details] | shared on every view
++--------------------------------+
+| Earlier messages ^ |
+| ! Session resume failed | |
+| Loading timed out. | | transcript scrolls
+| Recovered. [Details] | |
+| Agent: I resumed work... v |
++--------------------------------+
+| Message input | safe-area clearance
+| Chat | Plan | Files | More | existing navigation
++--------------------------------+
+```
+
+UI-03: Session entry states, inside either transcript.
+
+```text
+Unresolved: ! Resume failed. [Resume] [Recovery details]
+Pending: ! Resume failed. Resuming... [Details]
+Recovered: ! Resume failed. Recovered. [Details]
+Older: ! Resume failed. [Details] (no stale actions)
+```
+
+Expanded session details wrap inline. Phone actions stack with 44-pixel targets.
+A failed new attempt appends a separate error entry. Same-stamp updates retain position.
+
+UI-04: Shared details, desktop dialog / phone inset bottom drawer.
+
+```text
++--------------------------------+
+| Workspace preparation X |
+| Affected repository: owner/repo|
+| Safe cause and bounded details |
+| [Valid recovery action] |
++--------------------------------+
+```
+
+The phone drawer has safe-area clearance, an internally scrolling body, and 44-pixel controls.
+The desktop dialog uses compact controls. Closing details does not clear the shared alert.
+Views map to recovery criteria 006.4/6/7/8/9 and task criteria 002.4/5/6/7.
+Fixed versus scrolling regions, scope, order, and retained history are requirements.
+Copy and spacing are illustrative and must use localized strings and existing tokens.
+The two errors coexist only because they represent different failures.
+
+## Tests
+
+- Session criteria 006.4/7/8/9 and task 002.2/3/8: orchestrator failure persistence, repository fencing, processed-message filtering, action-message, and native-list tests.
+- Task 002.1/4/5/6/8: task status projector and rebuild tests, normalized client types, shared alert selection, and task layout tests.
+- Existing provider recovery and cancellation tests must preserve action semantics.
+- Use failure identity, not text, for duplicate tests. Include missing legacy metadata and a newer failed retry after successful recovery.
+
+## E2E tests
+
+Extend `tests/task/launch-failure-recovery.spec.ts` (chromium) and `tests/task/mobile-launch-failure-recovery.spec.ts` (mobile-chrome).
+Task 01 adds retained-history and scroll regressions. Task 02 adds shared-alert navigation and no-session regressions.
+Task 03 combines both scopes, reload, delayed events, short viewport, touch actions, and representative preview/Quick Chat coverage.
+The work orders specify exact commands and scenario names. Mock provider and workspace failures through existing fixtures.
+Use causal waits, not arbitrary sleeps. Rebuild through the managed runner.
+
+## Work orders
+
+- [x] [Task 01: Retain session failure entries](task-01-session-history.md)
+- [x] [Task 02: Show shared errors across task tabs](task-02-shared-task-errors.md)
+- [x] [Task 03: Verify scope and document recovery](task-03-regressions-and-docs.md)
+
+## Verification results
+
+Implementation is complete. Session failures now remain chronological transcript entries with stamp-scoped controls. Task and workspace preparation failures now project independently to one task-shell surface above task tabs, with desktop dialog and phone drawer details.
+
+The review remediation is included in this completed package. Accepted bootstrap failures now write the same idempotent, safe session-history entry as other recoverable failures, including Office sessions. The panel keeps a provisional error notice only until its persisted message arrives. Status-summary restoration honors explicit task/session scope even when a task error carries an originating session ID. Recovery retirement uses a stamp-fenced write and publishes inactive state only after that write wins.
+The review fixup also makes bootstrap admission fail closed when an execution-fenced repository commit is unavailable, repairs an accepted terminal state after a transcript write error, and preserves profile-specific launch failures on their originating session. The shared task surface refreshes from the live summary, emits one assertive announcement per task and stamp, and avoids nested mobile top-bar spacing.
+
+Product verification on September 14:
+
+- `pnpm --filter @kandev/web run build:vite`: passed.
+- `pnpm run typecheck`: passed.
+- `pnpm run lint`: passed.
+- `pnpm run i18n:check`: passed for all five complete catalogs.
+- `make lint` and the selected Go package tests for orchestrator, executor, SQLite, and status-summary: passed.
+- Review regression tests for bootstrap and Office history, persisted/provisional deduplication, scoped projector clearing, and stale recovery retirement: passed.
+- The complete selected Go package run passed: orchestrator, executor, SQLite, and status-summary.
+- Focused frontend regression tests passed: 6 files, 96 tests.
+- Chromium launch-recovery E2E: 4 passed.
+- Mobile Chrome launch-recovery E2E: 3 passed.
+- Public documentation validators and specification validators: passed.
+- `git diff --check`: passed.
+
+Review-fixup verification on September 14:
+
+- `go test ./internal/orchestrator -count=1`: passed.
+- The executor and status-summary package suites: passed.
+- The five affected frontend suites: 65 tests passed.
+- Frontend typecheck and lint: passed.
+- Frontend localization checks and Vite build: passed.
+- Legacy FAILED Office rows without structured metadata retain their chronological entry and recovery actions; the focused recovery suites pass with 54 tests.
+- Desktop and mobile PR watcher failures now exercise the task-shell error strip and both focused E2E specs pass.
+- Remaining CI recovery scenarios now assert their owning surface: the GitHub URL launch failure uses the shared task strip, Kubernetes failures use retained session entries with sanitized technical details, and failed mobile resume uses the persisted recovery entry. Focused follow-up runs pass for Chromium, Mobile Chrome, and the applicable Kubernetes container cases.
+
+The implementation changed production and permanent test files as required by the work orders. The provider timeout diagnosis and repair remain outside this package.
+
+Design validation on September 14:
+
+- `python3 scripts/list-docs.py validate`: passed, 268 decisions and 902 specifications.
+- `python3 scripts/lint-spec-files.test.py`: passed, 36 tests.
+- `python3 scripts/lint-spec-files.py --all`: passed.
+- Work-order requirement, acceptance, and system-design reference checks: passed for all three work orders.
+- `git diff --check -- docs/specs docs/decisions docs/plans`: passed.
+
+Task 03 updated the public session and task recovery guidance after implementation.
+
+## Risks
+
+- Duplicate websocket or terminal events can create duplicate history unless the durable write is fenced and idempotent.
+- Legacy errors lack stamps or retained markers. Preserve known history without inventing missing incidents.
+- Mixed-version aggregates cannot recover an untransmitted shared error. New boot/read/update contracts must agree.
+- Dockview maximization and phone navigation must not hide task-shell alerts.
+- Broad workspace text matching can misclassify session-only errors. Scope comes from the owner.
+- Existing completed plans contain historical reveal tests. Replace affected expectations and preserve unrelated guarantees.
+
+## Related decision
+
+[Error scope and history](../../decisions/2026-09-14-error-scope-and-history.md).
diff --git a/docs/plans/error-scope-and-history/task-01-session-history.md b/docs/plans/error-scope-and-history/task-01-session-history.md
new file mode 100644
index 0000000000..8dcc98db82
--- /dev/null
+++ b/docs/plans/error-scope-and-history/task-01-session-history.md
@@ -0,0 +1,183 @@
+---
+id: "01-session-history"
+title: "Retain session failure entries"
+status: complete
+wave: 1
+depends_on: []
+plan: "plan.md"
+requirements:
+ - REQ-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006
+ - REQ-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002
+acceptance_criteria:
+ - AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.1
+ - AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.2
+ - AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.3
+ - AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.4
+ - AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.5
+ - AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.6
+ - AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.7
+ - AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.8
+ - AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.9
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.2
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.3
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.8
+system_design:
+ - ../../specs/agents/system-design/session-recovery-failures.md
+ - ../../specs/tasks/system-design/task-launch-failure-recovery.md
+---
+
+# Task 01: Retain session failure entries
+
+## Summary
+
+Persist and render each accepted session failure as one chronological message. Preserve its body and details after recovery and later output.
+
+## In scope
+
+- Reuse failure admission, stable message identity, and stamp/execution fences. Persist bounded marker metadata before terminal publication.
+- Make duplicate same-stamp delivery idempotent. Failed successor attempts receive separate entries.
+- Remove message deletion after later activity and state-based body hiding. Separate pending, resolved, and superseded controls from history.
+- Migrate bootstrap cards into ordinary rows. Remove only error-specific prepend/reveal branches from detail, preview, simple Chat, and Quick Chat.
+- Preserve archived-task guards, provider-specific remediation, confirmation, cancellation, and workspace-only semantics.
+- Add localized historical/pending labels in all five catalogs. Generate Traditional Chinese with `pnpm run i18n:zh-hant`.
+
+## Out of scope
+
+Shared task projection, provider hang repair, and timeout changes.
+
+## Acceptance
+
+1. Same-stamp replay creates one persisted entry. Recovery and later output preserve it after reload, with no stale actions.
+2. Older history insertion and new messages obey ordinary scroll rules. No recovery-specific forced placement or duplicate banner remains.
+3. Desktop and phone preserve details and valid recovery actions, including automatic recovery followed by output.
+
+## ASCII UI preview
+
+UI-01: Desktop task, shared failure plus an independently recovered session error.
+
+```text
++--------------------------------------------------------+
+| Task title / workflow | fixed
+| ! Workspace preparation failed. [Recovery details] | shared
++--------------------------------------------------------+
+| Session A | Session B | Plan | Pull request | Files | tabs
++--------------------------------------------------------+
+| Earlier conversation ^ |
+| ! Session resume failed. 10:18 | |
+| Loading timed out. [Recovery details] | | scrolls
+| Recovered. | |
+| Agent: I resumed work... v |
++--------------------------------------------------------+
+| Message input | fixed
++--------------------------------------------------------+
+```
+
+UI-02: Phone task Chat, same two independent failures.
+
+```text
++--------------------------------+
+| < Task title Session A v | fixed task chrome
+| ! Workspace preparation failed |
+| [Recovery details] | shared on every view
++--------------------------------+
+| Earlier messages ^ |
+| ! Session resume failed | |
+| Loading timed out. | | transcript scrolls
+| Recovered. [Details] | |
+| Agent: I resumed work... v |
++--------------------------------+
+| Message input | safe-area clearance
+| Chat | Plan | Files | More | existing navigation
++--------------------------------+
+```
+
+UI-03: Session entry states, inside either transcript.
+
+```text
+Unresolved: ! Resume failed. [Resume] [Recovery details]
+Pending: ! Resume failed. Resuming... [Details]
+Recovered: ! Resume failed. Recovered. [Details]
+Older: ! Resume failed. [Details] (no stale actions)
+```
+
+Expanded session details wrap inline. Phone actions stack with 44-pixel targets.
+A failed new attempt appends a separate error entry. Same-stamp updates retain position.
+
+UI-04: Shared details, desktop dialog / phone inset bottom drawer.
+
+```text
++--------------------------------+
+| Workspace preparation X |
+| Affected repository: owner/repo|
+| Safe cause and bounded details |
+| [Valid recovery action] |
++--------------------------------+
+```
+
+The phone drawer has safe-area clearance, an internally scrolling body, and 44-pixel controls.
+The desktop dialog uses compact controls. Closing details does not clear the shared alert.
+Views map to recovery criteria 006.4/6/7/8/9 and task criteria 002.4/5/6/7.
+Fixed versus scrolling regions, scope, order, and retained history are requirements.
+Copy and spacing are illustrative and must use localized strings and existing tokens.
+The two errors coexist only because they represent different failures.
+
+See the [combined plan](plan.md#ascii-ui-preview). Task 01 implements the views within its scope.
+
+## Verification
+
+Run from the repository root. Install workspace dependencies once before the first package command if this checkout lacks them.
+Use `/tdd` for changed logic and `/e2e` for browser work. First establish the regression against current behavior.
+
+```bash
+(cd apps/backend && go test ./internal/orchestrator ./internal/orchestrator/executor ./internal/task/repository/sqlite)
+(cd apps/web && pnpm exec vitest run hooks/processed-message-filtering.test.ts components/task/chat/messages/action-message.test.tsx components/task/chat/message-list-native.test.tsx components/task/chat/message-list-native-scroll.test.ts components/task/task-chat-panel.launch-error.test.tsx components/quick-chat/quick-chat-session-view.test.tsx)
+(cd apps/web && pnpm run typecheck && pnpm run lint && pnpm run i18n:check)
+(cd apps/web && pnpm e2e:run --project chromium tests/task/launch-failure-recovery.spec.ts)
+(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-launch-failure-recovery.spec.ts)
+```
+
+## Files likely touched
+
+- `apps/backend/internal/orchestrator/event_handlers_agent.go` and existing failure/resolution tests.
+- `apps/backend/internal/orchestrator/executor/executor_launch_failure*` and `apps/backend/internal/task/repository/sqlite/` fenced metadata/message operations.
+- `apps/web/hooks/processed-message-filtering.ts` and its test.
+- `apps/web/components/task/chat/messages/action-message*`, `session-bootstrap-recovery-card.tsx`, and `message-list-{shared,native,native-scroll}*`.
+- `apps/web/components/task/task-chat-panel.tsx`, `simple/task-chat.tsx`, and `components/quick-chat/quick-chat-session-view.tsx`.
+- Existing tests named in verification. Add `action-message.test.tsx` if absent.
+- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/task.json`.
+- Existing desktop/mobile launch-failure E2E files.
+
+## Dependencies
+
+None.
+
+## Inputs
+
+Read both owning specs linked from the plan and the September 14 scope decision.
+Use the existing launch-recovery tests as the fixture pattern.
+
+## Risks
+
+Preserve failure identity, sanitized details, existing recovery guards, and message ordering under reversed delivery.
+The shared repository can contain other edits. Do not revert unrelated changes.
+
+## Parallelism
+
+`sequential`
+
+## Results
+
+Complete on September 14, 2026. Session failures are persisted as chronological, stamp-scoped messages. Same-stamp writes are idempotent, resolved entries retain their message and details, and later agent output no longer removes or hides them. The accepted bootstrap terminal path now produces the durable entry with safe bootstrap details and existing recovery choices, and Office failures use the same session-history producer. Profile-specific pre-agent failures retain their session scope. Bootstrap state admission requires an execution-fenced repository commit; when that commit succeeds but the transcript write fails, the executor retries the same idempotent message identity. Recovery-specific transcript reveal and prepend behavior was removed while ordinary older-history anchor restoration remains.
+
+Validation passed:
+
+- Backend orchestrator, executor, SQLite, and status-summary tests passed.
+- Focused frontend tests for processed-message filtering, action messages, native scrolling, task chat, Quick Chat, session recovery, and mobile layout passed.
+- Frontend typecheck, lint, i18n checks, and Vite build passed.
+- Real bootstrap admission, Office failure production, stale retirement fencing, and reload-retained history regressions passed.
+- Chromium and Mobile Chrome launch-recovery suites passed with 4 and 3 tests respectively.
+- The bootstrap repair regression and the profile-specific session ownership regression passed in the full orchestrator suite.
+
+Review follow-up validation:
+
+- Legacy FAILED Office sessions without `last_agent_error` metadata remain in the timeline with their existing recovery actions. The focused chat, recovery, and simple-chat suites passed 54 tests.
diff --git a/docs/plans/error-scope-and-history/task-02-shared-task-errors.md b/docs/plans/error-scope-and-history/task-02-shared-task-errors.md
new file mode 100644
index 0000000000..95b1365786
--- /dev/null
+++ b/docs/plans/error-scope-and-history/task-02-shared-task-errors.md
@@ -0,0 +1,172 @@
+---
+id: "02-shared-task-errors"
+title: "Show shared errors across task tabs"
+status: complete
+wave: 2
+depends_on:
+ - 01-session-history
+plan: "plan.md"
+requirements:
+ - REQ-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002
+acceptance_criteria:
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.1
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.4
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.5
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.6
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.7
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.8
+system_design:
+ - ../../specs/tasks/system-design/task-launch-failure-recovery.md
+---
+
+# Task 02: Show shared errors across task tabs
+
+## Summary
+
+Expose shared failures independently of session errors and render them once in the task shell.
+
+## In scope
+
+- Add optional scope to normalized metadata and wire types. Audit existing task admission, shared preparation, bootstrap, and resume producers.
+- Preserve originating session correlation without using it as scope. Legacy sessionless task metadata remains shared.
+- Add `task_error` to summary model, projector/rebuild, persistence, boot/HTTP/WS projection, and web state consumers.
+- Render a proposed `TaskSharedError` outside tab content, with dialog/Drawer details and existing guarded recovery actions.
+- Cover no-session, preview, mobile/tablet, full desktop, maximized panel, and plugin-tab paths.
+- Suppress equivalent active controls only by scope and stamp. Preserve unrelated session history and all existing authorization.
+- Localize new labels in the five catalogs and generate Traditional Chinese.
+
+## Out of scope
+
+A global notification center, a multi-incident queue, and inferred workspace-wide broadcasts.
+
+## Acceptance
+
+1. A shared failure survives newer session failures, session recovery, reload, and tab switches with unchanged identity.
+2. Exactly one shared control surface exists per task view. Same-stamp session controls do not duplicate it.
+3. Desktop and phone expose equivalent guarded actions with no overflow and reachable no-session recovery.
+
+## ASCII UI preview
+
+UI-01: Desktop task, shared failure plus an independently recovered session error.
+
+```text
++--------------------------------------------------------+
+| Task title / workflow | fixed
+| ! Workspace preparation failed. [Recovery details] | shared
++--------------------------------------------------------+
+| Session A | Session B | Plan | Pull request | Files | tabs
++--------------------------------------------------------+
+| Earlier conversation ^ |
+| ! Session resume failed. 10:18 | |
+| Loading timed out. [Recovery details] | | scrolls
+| Recovered. | |
+| Agent: I resumed work... v |
++--------------------------------------------------------+
+| Message input | fixed
++--------------------------------------------------------+
+```
+
+UI-02: Phone task Chat, same two independent failures.
+
+```text
++--------------------------------+
+| < Task title Session A v | fixed task chrome
+| ! Workspace preparation failed |
+| [Recovery details] | shared on every view
++--------------------------------+
+| Earlier messages ^ |
+| ! Session resume failed | |
+| Loading timed out. | | transcript scrolls
+| Recovered. [Details] | |
+| Agent: I resumed work... v |
++--------------------------------+
+| Message input | safe-area clearance
+| Chat | Plan | Files | More | existing navigation
++--------------------------------+
+```
+
+UI-03: Session entry states, inside either transcript.
+
+```text
+Unresolved: ! Resume failed. [Resume] [Recovery details]
+Pending: ! Resume failed. Resuming... [Details]
+Recovered: ! Resume failed. Recovered. [Details]
+Older: ! Resume failed. [Details] (no stale actions)
+```
+
+Expanded session details wrap inline. Phone actions stack with 44-pixel targets.
+A failed new attempt appends a separate error entry. Same-stamp updates retain position.
+
+UI-04: Shared details, desktop dialog / phone inset bottom drawer.
+
+```text
++--------------------------------+
+| Workspace preparation X |
+| Affected repository: owner/repo|
+| Safe cause and bounded details |
+| [Valid recovery action] |
++--------------------------------+
+```
+
+The phone drawer has safe-area clearance, an internally scrolling body, and 44-pixel controls.
+The desktop dialog uses compact controls. Closing details does not clear the shared alert.
+Views map to recovery criteria 006.4/6/7/8/9 and task criteria 002.4/5/6/7.
+Fixed versus scrolling regions, scope, order, and retained history are requirements.
+Copy and spacing are illustrative and must use localized strings and existing tokens.
+The two errors coexist only because they represent different failures.
+
+See the [combined plan](plan.md#ascii-ui-preview). Task 02 implements the views within its scope.
+
+## Verification
+
+Run from the repository root. Install workspace dependencies once before the first package command if this checkout lacks them.
+Use `/tdd` for changed logic and `/e2e` for browser work. First establish the regression against current behavior.
+
+```bash
+(cd apps/backend && go test ./internal/task/statussummary ./internal/task/repository/sqlite ./internal/orchestrator)
+(cd apps/web && pnpm exec vitest run lib/session-recovery-presentation.test.ts components/task/task-shared-error.test.tsx components/task/task-page-content.test.tsx components/task/mobile/session-mobile-layout.test.tsx components/task/task-chat-panel.launch-error.test.tsx)
+(cd apps/web && pnpm run typecheck && pnpm run lint && pnpm run i18n:check)
+(cd apps/web && pnpm e2e:run --project chromium tests/task/launch-failure-recovery.spec.ts)
+(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-launch-failure-recovery.spec.ts)
+```
+
+## Files likely touched
+
+- `apps/backend/internal/task/models/models.go` normalized errors and `internal/task/statussummary/{model,projector,projector_helpers,rebuild}.go` plus tests.
+- `apps/backend/internal/orchestrator/task_launch_recovery.go`, launch failure producers, and fenced task metadata operations.
+- Corresponding boot/HTTP/WS summary serialization and replacement consumers found through `TaskStatusSummary` references.
+- `apps/web/lib/types/task-status-summary.ts`, `lib/session-recovery-presentation.ts`, and task summary state tests.
+- Proposed `apps/web/components/task/task-shared-error.tsx` and `task-shared-error.test.tsx`.
+- `apps/web/components/task/task-page-content.tsx`, `task-layout.tsx`, preview composition, mobile/tablet layouts, and launch-error context.
+- Existing task recovery action components, five locale catalogs, and launch-failure E2E files.
+
+## Dependencies
+
+01-session-history.
+
+## Inputs
+
+Read both owning specs linked from the plan and the September 14 scope decision.
+Use the existing launch-recovery tests as the fixture pattern.
+
+## Risks
+
+Preserve failure identity, sanitized details, existing recovery guards, and message ordering under reversed delivery.
+The shared repository can contain other edits. Do not revert unrelated changes.
+
+## Parallelism
+
+`sequential`
+
+## Results
+
+Complete on September 14, 2026. Normalized failures now carry explicit session or task scope. Task-owned errors are projected independently as `task_error`, preserve originating session correlation, and render once above task content. Baseline restoration and fallback derivation route explicit scope before applying legacy session-ID inference, so clearing task metadata also clears the task-owned active error without a session refresh. The shared surface reads the live summary after hydration and emits one assertive announcement for each task and error stamp. Desktop details use a dialog. Phone details use a safe-area-aware, internally scrolling drawer with touch-sized controls and no nested top-bar padding. Existing recovery guards and legacy sessionless task errors remain supported.
+
+Validation passed:
+
+- Backend status-summary, SQLite, and orchestrator tests passed.
+- Frontend summary, recovery presentation, shared-shell, task layout, mobile layout, and launch-entry tests passed.
+- Frontend typecheck, lint, i18n checks, and Vite build passed.
+- Cold-projector clearing and composed-panel single-representation regressions passed.
+- Chromium and Mobile Chrome launch-recovery suites passed with 4 and 3 tests respectively.
+- The live-summary replacement, one-announcement remount, and mobile top-bar geometry regressions passed.
diff --git a/docs/plans/error-scope-and-history/task-03-regressions-and-docs.md b/docs/plans/error-scope-and-history/task-03-regressions-and-docs.md
new file mode 100644
index 0000000000..541ca3b605
--- /dev/null
+++ b/docs/plans/error-scope-and-history/task-03-regressions-and-docs.md
@@ -0,0 +1,188 @@
+---
+id: "03-regressions-and-docs"
+title: "Verify scope and document recovery"
+status: complete
+wave: 3
+depends_on:
+ - 02-shared-task-errors
+plan: "plan.md"
+requirements:
+ - REQ-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006
+ - REQ-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002
+acceptance_criteria:
+ - AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.5
+ - AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.6
+ - AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.7
+ - AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.8
+ - AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.9
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.1
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.2
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.3
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.4
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.5
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.6
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.7
+ - AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.8
+system_design:
+ - ../../specs/agents/system-design/session-recovery-failures.md
+ - ../../specs/tasks/system-design/task-launch-failure-recovery.md
+---
+
+# Task 03: Verify scope and document recovery
+
+## Summary
+
+Exercise the combined experience and update public recovery guidance after implementation.
+
+## In scope
+
+- Extend the existing desktop/phone suites with retained session history and shared-task error scenarios.
+- Named scenarios: `retains session failure after automatic recovery and output`, `preserves reading anchor while older history loads`, `keeps shared failure across session and Plan tabs`, `does not clear shared failure on sibling recovery`, and `legacy failure remains readable`.
+- Include a manual retry, another failure stamp, delayed old event, reload with recovery boot outside the loaded page, duplicate delivery, and no-session shared error.
+- Assert session error chronology and one action owner. Test short viewport, phone touch size, drawer bounds, focus return, safe areas, and no document overflow.
+- Exercise the preview and Quick Chat hosts in the same focused suites using their existing navigation fixtures.
+- Update `docs/public/tasks-and-workflows.md` as recovery how-to guidance. Explain retained session history and the task-wide strip.
+- Reconcile affected older tests and completed-plan successor notes without rewriting historical test results.
+
+## Out of scope
+
+Broad QA/review, production data mutation, and new provider diagnostics.
+
+## Acceptance
+
+1. Combined desktop and phone scenarios pass against a fresh build, including mixed-scope errors and resumed agent output.
+2. Rendered structure matches UI-01 through UI-04. Historical controls cannot affect a newer failure.
+3. Public guidance describes shipped behavior. Plan and work-order results record actual commands and outcomes.
+
+## ASCII UI preview
+
+UI-01: Desktop task, shared failure plus an independently recovered session error.
+
+```text
++--------------------------------------------------------+
+| Task title / workflow | fixed
+| ! Workspace preparation failed. [Recovery details] | shared
++--------------------------------------------------------+
+| Session A | Session B | Plan | Pull request | Files | tabs
++--------------------------------------------------------+
+| Earlier conversation ^ |
+| ! Session resume failed. 10:18 | |
+| Loading timed out. [Recovery details] | | scrolls
+| Recovered. | |
+| Agent: I resumed work... v |
++--------------------------------------------------------+
+| Message input | fixed
++--------------------------------------------------------+
+```
+
+UI-02: Phone task Chat, same two independent failures.
+
+```text
++--------------------------------+
+| < Task title Session A v | fixed task chrome
+| ! Workspace preparation failed |
+| [Recovery details] | shared on every view
++--------------------------------+
+| Earlier messages ^ |
+| ! Session resume failed | |
+| Loading timed out. | | transcript scrolls
+| Recovered. [Details] | |
+| Agent: I resumed work... v |
++--------------------------------+
+| Message input | safe-area clearance
+| Chat | Plan | Files | More | existing navigation
++--------------------------------+
+```
+
+UI-03: Session entry states, inside either transcript.
+
+```text
+Unresolved: ! Resume failed. [Resume] [Recovery details]
+Pending: ! Resume failed. Resuming... [Details]
+Recovered: ! Resume failed. Recovered. [Details]
+Older: ! Resume failed. [Details] (no stale actions)
+```
+
+Expanded session details wrap inline. Phone actions stack with 44-pixel targets.
+A failed new attempt appends a separate error entry. Same-stamp updates retain position.
+
+UI-04: Shared details, desktop dialog / phone inset bottom drawer.
+
+```text
++--------------------------------+
+| Workspace preparation X |
+| Affected repository: owner/repo|
+| Safe cause and bounded details |
+| [Valid recovery action] |
++--------------------------------+
+```
+
+The phone drawer has safe-area clearance, an internally scrolling body, and 44-pixel controls.
+The desktop dialog uses compact controls. Closing details does not clear the shared alert.
+Views map to recovery criteria 006.4/6/7/8/9 and task criteria 002.4/5/6/7.
+Fixed versus scrolling regions, scope, order, and retained history are requirements.
+Copy and spacing are illustrative and must use localized strings and existing tokens.
+The two errors coexist only because they represent different failures.
+
+See the [combined plan](plan.md#ascii-ui-preview). Task 03 implements the views within its scope.
+
+## Verification
+
+Run from the repository root. Install workspace dependencies once before the first package command if this checkout lacks them.
+Use `/tdd` for changed logic and `/e2e` for browser work. First establish the regression against current behavior.
+
+```bash
+(cd apps/web && pnpm e2e:run --project chromium tests/task/launch-failure-recovery.spec.ts)
+(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-launch-failure-recovery.spec.ts)
+node --test scripts/validate-public-docs.test.mjs
+node scripts/validate-public-docs.mjs
+python3 scripts/list-docs.py validate
+python3 scripts/lint-spec-files.py --all
+git diff --check
+```
+
+## Files likely touched
+
+- `apps/web/e2e/tests/task/launch-failure-recovery.spec.ts`.
+- `apps/web/e2e/tests/task/mobile-launch-failure-recovery.spec.ts` and existing fixture/page-object helpers if necessary.
+- `docs/public/tasks-and-workflows.md`.
+- This package and the linked owning specs, after implementation matches the design.
+
+## Dependencies
+
+02-shared-task-errors.
+
+## Inputs
+
+Read both owning specs linked from the plan and the September 14 scope decision.
+Use the existing launch-recovery tests as the fixture pattern.
+
+## Risks
+
+Preserve failure identity, sanitized details, existing recovery guards, and message ordering under reversed delivery.
+The shared repository can contain other edits. Do not revert unrelated changes.
+
+## Parallelism
+
+`sequential`
+
+## Results
+
+Complete on September 14, 2026. The desktop and phone fixtures cover retained session history, ordinary scroll anchoring, shared task errors across session and Plan surfaces, sibling recovery, legacy readability, duplicate stamps, reload, delayed output, no-session recovery, phone drawer bounds, touch targets, and document overflow. Backend and composed frontend regressions also cover the real bootstrap and Office producers, persisted/provisional representation deduplication, legacy recovery controls, cold-projector task scope, stale snapshot retirement, live shared-summary replacement, one assertive announcement per stamp, and mobile top-bar geometry. Public session and task recovery guidance now matches the shipped scope and history behavior.
+
+Validation passed:
+
+- `pnpm e2e:run --host --no-build --project chromium e2e/tests/task/launch-failure-recovery.spec.ts`: 4 passed.
+- `pnpm e2e:run --host --no-build --project mobile-chrome e2e/tests/task/mobile-launch-failure-recovery.spec.ts`: 3 passed.
+- `node --test scripts/validate-public-docs.test.mjs` and `node scripts/validate-public-docs.mjs`: passed.
+- `python3 scripts/list-docs.py validate` and `python3 scripts/lint-spec-files.py --all`: passed.
+- `git diff --check`: passed.
+- Review regression suites and the complete selected backend package run: passed.
+- The composed task panel assertion counts persisted and provisional recovery representations together and passed with the affected frontend suite.
+
+Review follow-up validation:
+
+- The desktop PR watcher missing-branch E2E now opens the task-shell error details and passed with one test.
+- The mobile PR watcher missing-branch E2E uses the same shared surface with touch interaction and passed with one test.
+- Full frontend lint and typecheck, plus the focused recovery suites, passed after the compatibility fixes.
+- The remaining recovery selectors now follow their owning scope: the GitHub URL launch case uses the shared task surface, Kubernetes failures use retained session entries with technical details, and failed mobile resume uses the persisted recovery entry. The focused follow-up runs passed: Chromium 1 test, Mobile Chrome 1 test, and Kubernetes containers 3 tests with 1 fixture-gated skip.
diff --git a/docs/plans/resume-cancellation/plan.md b/docs/plans/resume-cancellation/plan.md
index 56cb346dd9..954863376f 100644
--- a/docs/plans/resume-cancellation/plan.md
+++ b/docs/plans/resume-cancellation/plan.md
@@ -207,3 +207,9 @@ pass.
- Cleanup by session ID alone can terminate a replacement execution.
- Error deduplication without attempt correlation can hide historical failures.
- Request disconnect must remain distinct from explicit cancellation.
+
+## September 14 presentation successor
+
+The [error scope package](../error-scope-and-history/plan.md) supersedes the session card placement and removal behavior.
+Completed results here remain historical evidence. Provider recovery, timeout budgets, cancellation, and authorization remain unchanged.
+The successor owns chronological error retention, ordinary scroll behavior, and shared task alerts.
diff --git a/docs/plans/startup-recovery-scroll-timeout/plan.md b/docs/plans/startup-recovery-scroll-timeout/plan.md
index 83c9b90053..47e829b547 100644
--- a/docs/plans/startup-recovery-scroll-timeout/plan.md
+++ b/docs/plans/startup-recovery-scroll-timeout/plan.md
@@ -137,3 +137,9 @@ Targeted product tests and review regressions: passed.
A hung preflight can take longer to report failure. Cancellation must still work.
A new card reveal can conflict with bottom-follow or hidden-panel restoration.
Short viewports require the full card to share the transcript scroll area.
+
+## September 14 presentation successor
+
+The [error scope package](../error-scope-and-history/plan.md) supersedes the session card placement and removal behavior.
+Completed results here remain historical evidence. Provider recovery, timeout budgets, cancellation, and authorization remain unchanged.
+The successor owns chronological error retention, ordinary scroll behavior, and shared task alerts.
diff --git a/docs/public/sessions-and-review.md b/docs/public/sessions-and-review.md
index 5cf6abbf1b..3dd140ed47 100644
--- a/docs/public/sessions-and-review.md
+++ b/docs/public/sessions-and-review.md
@@ -59,9 +59,11 @@ Right-click an agent tab on desktop to manage it. Available actions depend on it
| **Handoff** | Opens the launch dialog with Blank context. Select a summary when you want to include this conversation |
| **Close Others** | Closes other visible agent panels without deleting their sessions |
-Stopping is not deletion. Resume succeeds only while the executor still has the session record needed to continue. A removed worktree, expired remote environment, restarted executor, removed profile, or missing runtime record can force a fresh session instead. When startup or resume fails, Kandev shows one recovery card in the selected session's chat. It labels the safe cause, keeps technical details collapsed, and offers **Resume**, **Restore read-only workspace**, or **Start fresh session** when each action is valid.
+Stopping is not deletion. Resume succeeds only while the executor still has the session record needed to continue. A removed worktree, expired remote environment, restarted executor, removed profile, or missing runtime record can force a fresh session instead. When startup or resume fails, Kandev adds one recovery entry to the selected session's chat. The entry stays in chronological history after recovery and later agent output. Only the current unresolved failure shows recovery controls. Older entries keep their message and technical details, and repeated delivery of the same failure does not create another entry. History loading and new messages use the normal chat scroll behavior.
-**Restore read-only workspace** makes the existing files available for inspection without claiming that the agent resumed. A successful restore keeps the recovery card visible until a later resume succeeds. Kandev keeps the card in the chat scroll area and uses stacked touch-sized actions on phones. A failure in another session or an unrelated provider error remains on its own existing surface.
+**Restore read-only workspace** makes the existing files available for inspection without claiming that the agent resumed. The session entry remains visible until the session resumes successfully. Kandev uses stacked touch-sized actions on phones. A failure in another session remains in that session's history.
+
+Failures during task or workspace preparation appear as one task error strip below the task header and above the session and Plan tabs. The strip remains visible when you switch sessions or tabs and disappears only after task recovery succeeds. Select **Show details** to open the available guarded actions in a desktop dialog or phone drawer.
Stopping a turn does not itself run the next queued message. If pending rows remain, Kandev sets their session's **Auto-run** switch to OFF. Expand the queue and turn Auto-run ON when you want FIFO processing to continue.
diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md
index 268a325c9b..62901c29fe 100644
--- a/docs/public/tasks-and-workflows.md
+++ b/docs/public/tasks-and-workflows.md
@@ -747,7 +747,9 @@ To restore a task, open **List**, enable **Show archived**, and choose **Unarchi
While a task is archived, Kandev shows its history but does not start its agent or restore its workspace. After a successful unarchive, the open task checks its existing session once and follows the normal start preference. It can resume the same session or restore its worktree while keeping the session and environment identity. If **Prevent auto-start on open** is enabled, select **Start agent** to begin recovery.
-If recovery fails, the task shows a short error with expandable details for the resume and workspace restore attempts. Select **Retry** to try again. If you archive the task during recovery, Kandev stops that recovery path and does not start a fallback restore. For worktree tasks, archive keeps the environment identity and the local branch. The next session recreates the worktree directory from that branch. Recovery is best-effort and does not rewrite ambiguous multi-row attachments for the same repository. If an external action or an older Kandev version removed the branch, Kandev also checks `origin`. If no branch exists, the next session starts from the base branch. Removed worktree directories, containers, and sandboxes are materialized again on a later launch rather than resumed in place.
+If session startup or resume fails, Kandev keeps the failure as a chronological entry in that session's chat. The current unresolved entry provides the valid recovery actions. After recovery and later agent output, the entry remains in the chat with its details, while older entries have no stale controls. History loading and new messages keep the normal chat scroll position.
+
+If task or workspace preparation fails, Kandev shows one task error strip below the task header and above the session and Plan tabs. The strip stays visible when you switch sessions or tabs. Select **Show details** to open the available guarded actions in a desktop dialog or phone drawer. Kandev clears the strip after task recovery succeeds. If you archive the task during recovery, Kandev stops that recovery path and does not start a fallback restore. For worktree tasks, archive keeps the environment identity and the local branch. The next session recreates the worktree directory from that branch. Recovery is best-effort and does not rewrite ambiguous multi-row attachments for the same repository. If an external action or an older Kandev version removed the branch, Kandev also checks `origin`. If no branch exists, the next session starts from the base branch. Removed worktree directories, containers, and sandboxes are materialized again on a later launch rather than resumed in place.
Delete is permanent. If **Also delete _N_ subtasks** is left unchecked, direct children become root tasks. If selected, descendants are deleted. The operation cannot be undone, and executor cleanup follows the same asynchronous retry and restart-reconciliation rules as archive.
diff --git a/docs/specs/agents/requirements/session-recovery-failures.md b/docs/specs/agents/requirements/session-recovery-failures.md
index 220342c5a6..06bf2e7be8 100644
--- a/docs/specs/agents/requirements/session-recovery-failures.md
+++ b/docs/specs/agents/requirements/session-recovery-failures.md
@@ -2,7 +2,7 @@
status: active
system: agents
created: 2026-09-11
-updated: 2026-09-12
+updated: 2026-09-14
owners:
- Kandev
---
@@ -42,15 +42,22 @@ results, including the shared recovery owner and phone touch-target checks.
- **AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.1:** When the same failure appears in automatic recovery, session state, and the transcript, the selected session shall show one active recovery card. An equivalent top banner, stopped-session warning, or synthetic agent error shall not repeat it.
- **AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.2:** The card shall show a localized cause summary, valid actions, and one initially collapsed details disclosure. Resume and restore causes shall have separate labels and bounded, sanitized details.
- **AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.3:** A failure before agent startup shall be described as startup or recovery failure. It shall not state that the agent encountered an error while working.
-- **AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.4:** Retry shall update the existing card and disable equivalent actions while pending. Successful resume shall clear the active failure; workspace-only success shall show a nonblocking notice that the agent remains stopped.
+- **AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.4:** Retry shall show pending state on the error entry and disable equivalent actions. Successful resume shall retire its actions without removing history. Workspace-only success shall retain the stopped-agent notice.
- **AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.5:** Reload, reconnect, and reversed event order shall converge on the current failure. A stale attempt or unrelated historical error shall neither replace nor be hidden by that failure.
- **AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.6:** Desktop and phone shall expose the same recovery choices and details. Phone actions shall have at least 44-pixel touch targets, with no horizontal page overflow or extra details scroller.
-- **AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.7:** Chat shall use one vertical scroll area for the recovery card, expanded details, and transcript. Opening a session with an active failure, or receiving a new failure, shall reveal the card. Later user scrolling shall remain under user control. Recovery actions and the composer shall remain reachable on desktop and phone.
+- **AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.7:** Chat shall show the session error at its chronological position in the transcript. Normal message scrolling shall govern initial placement, pagination, and new entries. Errors shall not force a separate scroll position. Recovery actions and the composer shall remain reachable on desktop and phone.
-## Proposed recovery scrolling amendment
+- **AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.8:** After manual or automatic recovery, the session error shall remain readable before later messages. It shall retain its original cause and occurrence time after reload.
+- **AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.9:** Only the current unresolved failure shall offer recovery actions. Pending recovery shall not imply success. A later failure shall not reactivate controls on an older entry.
-This amendment is draft. The [startup recovery fix package](../../../plans/startup-recovery-scroll-timeout/plan.md) owns implementation of criterion 006.7.
+## Session error history amendment
+
+The September 14 amendment changes criteria 006.4 and 006.7 and adds 006.8 and 006.9.
+Implementation is complete in the [error scope package](../../../plans/error-scope-and-history/plan.md).
+This supersedes the reveal-at-top behavior from the completed startup recovery scrolling package.
+The task system owns durable history and shared error scope through
+[task error ownership](../../tasks/requirements/task-launch-failure-recovery.md).
## Proposed recovery attempt amendment
diff --git a/docs/specs/agents/system-design/session-recovery-failures.md b/docs/specs/agents/system-design/session-recovery-failures.md
index 70ec77d44c..2902f3ad09 100644
--- a/docs/specs/agents/system-design/session-recovery-failures.md
+++ b/docs/specs/agents/system-design/session-recovery-failures.md
@@ -2,7 +2,7 @@
status: draft
system: agents
created: 2026-09-11
-updated: 2026-09-12
+updated: 2026-09-14
requirements:
- REQ-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-005
- REQ-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006
@@ -97,24 +97,40 @@ Use 28-pixel fine-pointer buttons and at least 44-pixel phone/coarse-pointer
targets. The semantic disclosure supports Enter/Space and expanded state.
-### Proposed recovery scroll ownership
+### Chronological recovery entries (September 14 amendment)
-The [startup recovery fix package](../../../plans/startup-recovery-scroll-timeout/plan.md) implements criterion 006.7.
-Place the recovery card inside the existing transcript viewport, before the message content.
-Disable outer `PanelBody` scrolling for the mounted chat.
-Preserve the fixed composer and existing dynamic viewport and safe-area layout.
-Expanded recovery details remain in the transcript scroll area.
-Do not add a card scroller, duplicate banner, or permanent fixed recovery panel.
+This amendment supersedes recovery reveal and prepend placement from the completed
+[startup recovery scrolling package](../../../plans/startup-recovery-scroll-timeout/plan.md).
+The [error scope package](../../../plans/error-scope-and-history/plan.md) owns implementation.
-Reveal the active card once per session and failure stamp when the chat becomes visible.
-Use the existing transcript scroll controller to coordinate this reveal with initial bottom positioning.
-A repeated event with the same stamp must not pull the user away from older messages.
-Scope reveal state to session identity and defer hidden-panel scrolling until visibility returns.
-Preserve normal bottom-follow behavior when no active recovery card exists.
+Render a session failure through the ordinary message pipeline at its persisted occurrence position.
+Reuse `ActionMessage`, `RunErrorEntry`, and `SessionBootstrapRecoveryCard` presentation and recovery handlers where applicable.
+One correlated failure selects one renderer, not a footer plus a prepended card.
+`TaskChatLaunchError` remains an adapter for compatible callers until those callers migrate.
-Task detail and preview share the chat panel. Audit Quick Chat for the same nested-scroll condition.
-The dedicated phone Chat surface retains stacked touch actions and one-dimensional navigation.
-The recovery card remains the sole explanation and action owner.
+Remove activity-based deletion from `deduplicateRecoveryMessages` in `processed-message-filtering.ts`.
+Deduplicate by session and durable stamp, with message ID as the legacy fallback.
+Do not collapse every recovery entry into the latest one. Preserve unrelated errors and provider-specific remediation.
+`ActionMessage` must retain a historical body while the session is STARTING, RUNNING, or COMPLETED.
+Neither a button click nor any later user message proves successful recovery.
+Use the existing durable recovery resolution timestamp and correlated boot evidence for outcome state.
+Only the matching current unresolved stamp can mount active recovery controls.
+A newer failed attempt creates a new chronological entry. The previous entry remains historical with no stale actions.
+An update within the same failure identity updates that entry without moving it.
+
+Remove recovery-specific `prependContent`, `recoveryRevealKey`, and top-placement branches from mounted chat paths.
+Remove only error-specific scroll behavior, preserving search, ordinary history anchors, bottom follow, and environment-switch placement.
+Update detail, preview, simple Chat, and Quick Chat. Keep the composer outside the single transcript scroll owner.
+A following reader sees new errors through normal append behavior. A reader in history stays at the same anchor.
+An active error outside the loaded page must not be fabricated at the tail.
+The existing session metadata can supply one provisional entry at its occurrence time until the persisted marker arrives.
+Merge that entry by stamp and replace it with the persisted message identity without duplication.
+After recovery, never reconstruct absent historical errors from current state alone.
+
+Phone entries stack actions with 44-pixel targets. Their details expand inline and wrap in the transcript.
+Shared task errors use the task-owned shell surface described in the
+[task design](../../tasks/system-design/task-launch-failure-recovery.md).
+The [scope decision](../../../decisions/2026-09-14-error-scope-and-history.md) records the tradeoffs.
## Persistence and compatibility
@@ -209,8 +225,8 @@ Explicit cancellation is a cancellation outcome, not a resume failure card.
Reuse the existing `TaskLaunchErrorEntry` and recovery view model. The summary
identifies recovery failure. Details identify the load timeout. Existing Retry
-and confirmed Start fresh actions keep their semantics. No new layout, setting,
-public retry endpoint, or background retry loop is necessary.
+and confirmed Start fresh actions keep their semantics. No new setting, public retry endpoint, or background retry loop is necessary.
+The chronological presentation amendment governs the layout.
The nearest phone exemplar is the existing inline launch recovery card in
`mobile/session-mobile-layout.tsx`. Phone actions stack below the summary and
diff --git a/docs/specs/tasks/requirements/task-launch-failure-recovery.md b/docs/specs/tasks/requirements/task-launch-failure-recovery.md
index ddc767205c..a040f79d67 100644
--- a/docs/specs/tasks/requirements/task-launch-failure-recovery.md
+++ b/docs/specs/tasks/requirements/task-launch-failure-recovery.md
@@ -2,7 +2,7 @@
status: active
system: tasks
created: 2026-08-19
-updated: 2026-09-11
+updated: 2026-09-14
owners:
- cfl12
---
@@ -71,6 +71,28 @@ These criteria are implemented in the
with execution fencing, transaction-serialized ownership, and recovery-surface
regressions recorded in its verification results.
+### REQ-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002: Error ownership and retained session history
+
+**Intent:** Keep session failures in their conversation and shared failures visible across the affected task.
+
+This September 14 amendment is implemented in the [error scope package](../../../plans/error-scope-and-history/plan.md).
+It changes presentation and retention, not recovery permissions or provider resume behavior.
+
+#### Acceptance criteria
+
+- **AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.1:** Each failure shall identify session or task scope. A shared resource failure shall name its affected resource when known. An initiating session shall not make a shared failure session-scoped.
+- **AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.2:** A session failure shall create one durable chronological entry in its owning session. Repeated delivery of the same failure shall not create another entry.
+- **AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.3:** Successful recovery, later messages, reload, and reconnect shall preserve the original session error. Recovery shall retire only matching active error state.
+- **AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.4:** An active shared error shall appear once below the task header and above tab content. It shall remain visible on session, Plan, PR, Files, and plugin views. Task previews and tasks without sessions shall expose the same error.
+- **AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.5:** A newer session error or session recovery shall not replace or clear an unresolved shared error. An unrelated session shall not display another session's error as its own.
+- **AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.6:** One failure shall have one recovery control surface per task view. A shared error shall not repeat as an actionable banner inside each session. Separate failures can remain visible together.
+- **AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.7:** Phone users shall see the shared error outside tab content and reach its details and actions from a bottom drawer. Targets shall measure at least 44 pixels. Desktop shall use a compact shared strip and details dialog.
+- **AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002.8:** Older records shall remain readable without invented failures or guessed resource scope. Stale recovery requests shall remain unable to mutate a successor error.
+
+Implementation belongs to the [error scope package](../../../plans/error-scope-and-history/plan.md).
+Session action presentation remains owned by the
+[agent recovery requirement](../../agents/requirements/session-recovery-failures.md).
+
## Out of scope
- A background poller for all repository defaults.
diff --git a/docs/specs/tasks/system-design/task-launch-failure-recovery.md b/docs/specs/tasks/system-design/task-launch-failure-recovery.md
index 8bc20b4042..b7ca4cc940 100644
--- a/docs/specs/tasks/system-design/task-launch-failure-recovery.md
+++ b/docs/specs/tasks/system-design/task-launch-failure-recovery.md
@@ -1,10 +1,11 @@
---
-status: current
+status: draft
system: tasks
requirements:
- REQ-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001
+ - REQ-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002
created: 2026-08-24
-updated: 2026-09-11
+updated: 2026-09-14
owners:
- cfl12
---
@@ -22,7 +23,8 @@ task-owned projection.
| Requirement | Design sections |
| --- | --- |
-| REQ-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001 | PR gate, initial prompt admission, error projection, recovery actions, responsive surface |
+| REQ-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001 | PR gate, initial prompt admission, error projection, recovery actions |
+| REQ-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-002 | Error scope, durable session history, shared task surface |
## PR gate and launch paths
@@ -203,53 +205,83 @@ Initial-prompt errors use a safe generic durable message and the same
stale-event checks as other agent failures. Raw attachment paths and provider
details remain in backend diagnostics and do not reach the durable projection.
-## Launch-error presentation ownership
+## Error scope (September 14 amendment)
-The active `TaskStatusSummary.active_error` record owns the primary launch
-error for its matching session and stamp. A task-wide record remains visible
-while a prior session is selected, but it owns session surfaces only when no
-session is selected. A session-owned record owns surfaces only for its exact
-session and stamp. The task Chat surface renders one persistent card from this
-record.
+Implementation is complete in the [error scope package](../../../plans/error-scope-and-history/plan.md).
+This section supersedes the earlier Chat-only task error surface.
+Tasks own durable error records and task-shell projections. Agents own session recovery eligibility and entry presentation.
+Workspace services retain authority over resource validity. This change does not add a workspace-wide alert bus.
-The matching launch error suppresses these secondary presentations:
+Add an optional `scope` discriminator, `session` or `task`, to existing normalized error metadata and wire types.
+The producer selects scope from operation ownership, never from English error text or a count of failed sessions.
+Session bootstrap, provider, turn, and resume failures default to session scope.
+Task admission and preparation failures before session creation remain task scope.
+A shared workspace or repository preparation failure uses task scope only when its owner establishes shared impact.
+Preserve `task_repository_id` and the originating session correlation when available.
+A repository ID alone does not prove shared impact. Unknown legacy session records remain session-scoped.
+Legacy task metadata without scope remains task-scoped. Reject or safely ignore malformed optional scope values.
-- The previous-agent-error notice.
-- The standalone failed preparation row.
-- The empty-turn warning.
-- The generic failed-agent status row.
-- The stopped-session recovery banner.
-- A launch-error toast while the user views the affected task.
+Continue storing current session errors in `task_sessions.metadata.last_agent_error`.
+Store the current shared failure in `tasks.metadata.last_launch_error`, with the existing stamp fencing and recovery validation.
+Do not create the same active failure in both stores. An origin session can retain a non-actionable historical reference.
+Recovery lookup uses scope to choose the source record. The originating session ID is correlation, not authority to select session metadata.
+Legacy requests retain their existing lookup rules. Scoped requests must pass the same task, repository, and stamp guards.
+The existing single current task error contract remains. This package does not add a multi-incident queue.
-Only synthetic rows generated by the active launch failure are suppressed;
-stale stamped or historical launch-only rows remain visible. An unrelated
-historical error keeps its normal transcript presentation. A runtime error that
-occurs after agent startup keeps the existing session
-recovery surface.
+Expose optional `TaskStatusSummary.task_error` independently of `active_error`.
+The projector already tracks `taskError` separately from its session error map.
+Use that source for the shared field, while preserving `active_error` for existing aggregate consumers.
+Boot payloads, HTTP reads, summary persistence, rebuilds, equality checks, and WebSocket replacements must carry the same field.
+A newer session failure cannot displace `task_error`. Session success cannot clear task metadata.
+Clear the shared record only through its matching successful recovery or an authoritative resource-resolution event.
+For old payloads, clients can use an explicitly task-scoped or sessionless `active_error` as a compatibility fallback.
+An old aggregate cannot reconstruct a shared error that it never carried.
-The card shows a category title, a short cause, a no-change statement, and only
-valid recovery actions. One disclosure shows bounded technical details. The
-disclosure does not show raw Git output, credentials, or local file paths.
+## Durable session history
-A failed recovery updates the same card. It does not add another banner or
-toast. A successful recovery removes the card after launch succeeds.
+Use existing persisted session messages as the history ledger. No new history table or frontend error store is required.
+Persist one sanitized error marker for each accepted session failure stamp before publishing the terminal state.
+The marker carries session, execution, attempt, stamp, occurrence time, phase, and bounded cause data when available.
+Reuse the current failure path and message storage rather than writing a second copy from the browser.
+Extend the fenced persistence boundary so marker creation and active error admission cannot diverge under duplicate or late delivery.
+An operation that loses its execution or stamp fence must not insert a failure for the successor.
+Use deterministic message identity derived from the owning session and failure stamp for idempotent insertion.
-## Responsive presentation
+Successful recovery clears matching active metadata and retains `recovery_resolved_at` under existing rules.
+It must not delete the error marker. Client presentation reads resolution separately from historical content.
+Workspace-only restoration does not resolve the stopped agent. New agent output does not resolve shared workspace state.
+A failed retry has a new attempt/stamp and a new entry. Request feedback for the same stamp updates only that entry.
+Old records with markers remain readable. Metadata-only legacy failures can use the agent design's provisional entry while unresolved.
+No migration invents historical errors after metadata has already been cleared.
-The task Chat surface is the entry point on desktop and phone. The card remains
-inline because the error and its actions belong to the current task.
+## Shared task surface
-Desktop actions use one compact row. Phone actions use a vertical layout with
-44-pixel touch targets. The existing mobile picker remains the branch-selection
-surface for `base_branch_missing` and `default_branch_unresolved`.
+Mount one shared alert in the task shell below the task title/header and above session and content tabs.
+It is outside Dockview and mobile tab content, including maximized panels and tasks without sessions.
+Audit `task-page-content.tsx`, `task-layout.tsx`, preview composition, and dedicated phone/tablet layouts to choose the common host.
+Extract a proposed `TaskSharedError` view instead of mounting `TaskChatLaunchError` inside every chat.
+The compact strip shows the safe cause, affected resource label, and a Recovery details control.
+A desktop dialog exposes existing valid task recovery actions. The phone equivalent uses an inset Drawer.
+Use the existing branch picker and confirmation controls inside their established flows.
+The alert has no dismiss action that clears unresolved state. Details can close without resolving the error.
+Preserve focus return and use an accessible status announcement once for a new stamp.
-The transcript owns vertical scrolling. Expanded technical details wrap inside
-the card and do not create a second scroll region. Both layouts prevent
-document-level horizontal overflow.
+A shared alert and an unrelated session error can coexist. They represent separate failures.
+The same stamp cannot mount recovery controls in both places.
+Session error filtering must not hide the durable historical marker merely because a shared alert is present.
+Session switching, Plan/PR navigation, and message pagination do not affect the shared alert.
-`useEnsureTaskSession` keeps one request latch after an error. Store updates and
-loader identity changes do not start another request. The Retry control starts
-the next request explicitly. A task change clears the prior task's error state.
+## Responsive behavior
+
+Phone entry: the task header above the selected session/content view.
+Exemplars: `mobile/session-mobile-layout.tsx` for task chrome and
+`mobile/mobile-picker-sheet.tsx` for the inset drawer interaction.
+The strip provides summary and details entry. The drawer provides cause, resource, then recovery actions.
+This occasional multi-action recovery fits a temporary drawer, while the transcript remains the main reading surface.
+Use one internal drawer body scroller for long shared details, dynamic viewport bounds, and bottom safe-area clearance.
+Phone targets measure at least 44 pixels. Fine-pointer controls retain 28-pixel sizing.
+Session entry details remain inline in the transcript and never gain a nested details scroller.
+Shared view models, action guards, stamps, and request state serve both presentations.
## Verification
@@ -262,3 +294,5 @@ the next request explicitly. A task change clears the prior task's error state.
## Related decisions
- [ADR-2026-08-18-never-started-agent-stall-terminal](../../../decisions/2026-08-18-never-started-agent-stall-terminal.md)
+
+- [Error scope and history decision](../../../decisions/2026-09-14-error-scope-and-history.md)