diff --git a/apps/backend/internal/orchestrator/ceiling_session_keyed_reservation.go b/apps/backend/internal/orchestrator/ceiling_session_keyed_reservation.go index 5dd6e0dac2..af19c99dbd 100644 --- a/apps/backend/internal/orchestrator/ceiling_session_keyed_reservation.go +++ b/apps/backend/internal/orchestrator/ceiling_session_keyed_reservation.go @@ -2,6 +2,7 @@ package orchestrator import ( "context" + "errors" "go.uber.org/zap" @@ -85,9 +86,14 @@ func (s *Service) admitOrDeferSessionKeyedLaunch( if err := s.deferCeilingRefusal(ctx, taskID, sessionID, kind, payload, decision.reasonCode, decision.population, decision.populationKnown, decision.ceiling); err != nil { - s.logger.Zap().Error("could not persist a ceiling deferral; "+failureContext, - zap.String("task_id", taskID), zap.String("session_id", sessionID), - zap.String(ceilingFieldReasonCode, ceilingReasonDeferWriteFailed), zap.Error(err)) + if errors.Is(err, ErrCeilingLaunchConflict) && isSessionOpenRecoveryContext(ctx) { + s.logger.Zap().Debug("session-open recovery left the existing ceiling queue unchanged after capacity refusal", + zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.Error(err)) + } else { + s.logger.Zap().Error("could not persist a ceiling deferral; "+failureContext, + zap.String("task_id", taskID), zap.String("session_id", sessionID), + zap.String(ceilingFieldReasonCode, ceilingReasonDeferWriteFailed), zap.Error(err)) + } return nil, false, err } return nil, true, nil diff --git a/apps/backend/internal/orchestrator/session_launch.go b/apps/backend/internal/orchestrator/session_launch.go index 4485dec492..3739e89d72 100644 --- a/apps/backend/internal/orchestrator/session_launch.go +++ b/apps/backend/internal/orchestrator/session_launch.go @@ -44,6 +44,31 @@ const ( activationDispositionQueued = "queued" ) +type sessionOpenRecoveryContextKey struct{} + +// sessionOpenRecoveryBlockedError carries a guarded ownership decision back to +// LaunchSession. Passive inspection treats that decision as a successful +// waiting response so the browser does not enter workspace recovery. +type sessionOpenRecoveryBlockedError struct { + reason string +} + +func (e *sessionOpenRecoveryBlockedError) Error() string { + return "session_open recovery blocked: " + e.reason +} + +func withSessionOpenRecoveryContext(ctx context.Context) context.Context { + return context.WithValue(ctx, sessionOpenRecoveryContextKey{}, true) +} + +func isSessionOpenRecoveryContext(ctx context.Context) bool { + if ctx == nil { + return false + } + active, _ := ctx.Value(sessionOpenRecoveryContextKey{}).(bool) + return active +} + // LaunchSessionRequest is the unified request for session.launch. type LaunchSessionRequest struct { TaskID string `json:"task_id"` @@ -490,12 +515,23 @@ func (s *Service) launchStartCreated(ctx context.Context, req *LaunchSessionRequ // launchResume resumes a stopped session. func (s *Service) launchResume(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) { parkingStamp := s.captureWorkflowParkingStamp(ctx, req.SessionID) - execution, err := s.ResumeTaskSessionWithOptions(ctx, req.TaskID, req.SessionID, executor.ResumeOptions{ + resumeCtx := ctx + if req.ActivationSource == LaunchActivationSourceSessionOpen { + resumeCtx = withSessionOpenRecoveryContext(ctx) + } + execution, err := s.ResumeTaskSessionWithOptions(resumeCtx, req.TaskID, req.SessionID, executor.ResumeOptions{ AllowBranchReplacement: req.AllowBranchReplacement, AllowCompletedSessionResume: req.AllowCompletedSessionResume, Origin: string(launchOriginForActivation(req)), }) if err != nil { + var blocked *sessionOpenRecoveryBlockedError + if errors.As(err, &blocked) { + return s.sessionOpenRecoveryWaitingResponse(ctx, req, blocked.reason), nil + } + if req.ActivationSource == LaunchActivationSourceSessionOpen && errors.Is(err, ErrCeilingLaunchConflict) { + return s.sessionOpenRecoveryWaitingResponse(ctx, req, "session_capacity"), nil + } return nil, err } if execution == nil { @@ -509,6 +545,30 @@ func (s *Service) launchResume(ctx context.Context, req *LaunchSessionRequest) ( return executionToLaunchResponse(req.TaskID, execution), nil } +func (s *Service) sessionOpenRecoveryWaitingResponse( + ctx context.Context, + req *LaunchSessionRequest, + reason string, +) *LaunchSessionResponse { + var session *models.TaskSession + if s != nil && s.repo != nil && req != nil { + session, _ = s.repo.GetTaskSession(ctx, req.SessionID) + } + disposition := activationDispositionSuppressed + if reason == autoResumeBlockedLaunchQueued { + disposition = activationDispositionQueued + } + return &LaunchSessionResponse{ + Success: true, + TaskID: req.TaskID, + SessionID: req.SessionID, + State: sessionStateOrEmpty(session), + AgentProfileID: sessionProfileOrEmpty(session), + ActivationDisposition: disposition, + ActivationReason: reason, + } +} + func launchOriginForActivation(req *LaunchSessionRequest) launchOrigin { if req != nil && req.ActivationSource == LaunchActivationSourceSessionOpen { return launchOriginAutomatic diff --git a/apps/backend/internal/orchestrator/session_launch_test.go b/apps/backend/internal/orchestrator/session_launch_test.go index b6b013684d..835f42b130 100644 --- a/apps/backend/internal/orchestrator/session_launch_test.go +++ b/apps/backend/internal/orchestrator/session_launch_test.go @@ -189,7 +189,7 @@ func TestValidateLaunchActivationSource(t *testing.T) { } } -func TestPassiveLaunchResponseSuppressesParkedSession(t *testing.T) { +func TestPassiveLaunchResponseAllowsParkedSession(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) @@ -213,20 +213,14 @@ func TestPassiveLaunchResponseSuppressesParkedSession(t *testing.T) { } service := &Service{repo: repo} - response, err := service.LaunchSession(ctx, &LaunchSessionRequest{ + response := service.passiveLaunchResponse(ctx, &LaunchSessionRequest{ TaskID: "task1", SessionID: "session1", Intent: IntentResume, ActivationSource: LaunchActivationSourceSessionOpen, - }) - if err != nil { - t.Fatalf("LaunchSession: %v", err) - } - if response == nil || response.ActivationDisposition != "suppressed" { - t.Fatalf("passive parked launch response = %#v, want suppressed disposition", response) - } - if response.ActivationReason != autoResumeBlockedWorkflowParked { - t.Fatalf("passive parked launch reason = %q, want %q", response.ActivationReason, autoResumeBlockedWorkflowParked) + }, IntentResume) + if response != nil { + t.Fatalf("passive parked launch response = %#v, want no suppression", response) } updated, err := repo.GetTaskSession(ctx, "session1") if err != nil { @@ -359,7 +353,7 @@ func TestClearWorkflowParkingForSessionPreservesNewerMarkerAndStopIntent(t *test } } -func TestPassiveLaunchResponseBlocksAmbiguousLegacyParking(t *testing.T) { +func TestPassiveLaunchResponseAllowsLegacyStopIntent(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) @@ -383,18 +377,14 @@ func TestPassiveLaunchResponseBlocksAmbiguousLegacyParking(t *testing.T) { } service := &Service{repo: repo} - response, err := service.LaunchSession(ctx, &LaunchSessionRequest{ + response := service.passiveLaunchResponse(ctx, &LaunchSessionRequest{ TaskID: "task1", SessionID: "session1", Intent: IntentResume, ActivationSource: LaunchActivationSourceSessionOpen, - }) - if err != nil { - t.Fatalf("LaunchSession: %v", err) - } - if response == nil || response.ActivationDisposition != "suppressed" || - response.ActivationReason != autoResumeBlockedOwnershipUnavailable { - t.Fatalf("ambiguous legacy passive response = %#v", response) + }, IntentResume) + if response != nil { + t.Fatalf("legacy stop-intent passive response = %#v, want no suppression", response) } } diff --git a/apps/backend/internal/orchestrator/session_open_recovery_launch_test.go b/apps/backend/internal/orchestrator/session_open_recovery_launch_test.go new file mode 100644 index 0000000000..0547c66a20 --- /dev/null +++ b/apps/backend/internal/orchestrator/session_open_recovery_launch_test.go @@ -0,0 +1,583 @@ +package orchestrator + +import ( + "context" + "reflect" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/orchestrator/watcher" + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.3 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.5 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.7 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.8 +func TestSessionOpenRecoveryStatusAndLaunch(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSessionOpenRecoveryState(t, repo, models.TaskSessionStateWaitingForInput) + + started := false + launchCalls := 0 + agentMgr := &sessionUpdatingAgentManager{ + mockAgentManager: &mockAgentManager{ + repoForExecutionLookup: repo, + launchAgentFunc: func(_ context.Context, _ *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + launchCalls++ + running, err := repo.GetExecutorRunningBySessionID(context.Background(), "session-reused") + if err != nil { + return nil, err + } + running.AgentExecutionID = "execution-successor" + if err := repo.UpsertExecutorRunning(context.Background(), running); err != nil { + return nil, err + } + return &executor.LaunchAgentResponse{ + AgentExecutionID: "execution-successor", + Status: v1.AgentStatusStarting, + }, nil + }, + }, + repo: repo, + sessionID: "session-reused", + taskID: "task-reused", + onStartCalled: &started, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + svc.sessionCeiling = newSessionCeilingController(unlimitedSessionCeiling, nil, nil) + + status, err := svc.GetTaskSessionStatus(ctx, "task-reused", "session-reused") + if err != nil { + t.Fatalf("GetTaskSessionStatus: %v", err) + } + if !status.AutoResumeAllowed { + t.Fatalf("auto_resume_allowed = false, reason %q", status.AutoResumeBlockedReason) + } + if !status.NeedsResume || !status.IsResumable { + t.Fatalf("status = %+v, want a resumable session that needs recovery", status) + } + + passive := svc.passiveLaunchResponse(ctx, &LaunchSessionRequest{ + TaskID: "task-reused", + SessionID: "session-reused", + Intent: IntentResume, + ActivationSource: LaunchActivationSourceSessionOpen, + }, IntentResume) + if passive != nil { + t.Fatalf("passive launch response = %+v, want normal recovery", passive) + } + explicit := svc.passiveLaunchResponse(ctx, &LaunchSessionRequest{ + TaskID: "task-reused", + SessionID: "session-reused", + Intent: IntentResume, + ActivationSource: LaunchActivationSourceUserAction, + }, IntentResume) + if explicit != nil { + t.Fatalf("explicit launch response = %+v, want no passive disposition", explicit) + } + response, err := svc.LaunchSession(ctx, &LaunchSessionRequest{ + TaskID: "task-reused", + SessionID: "session-reused", + Intent: IntentResume, + ActivationSource: LaunchActivationSourceSessionOpen, + }) + if err != nil { + t.Fatalf("LaunchSession: %v", err) + } + if response == nil || !response.Success || response.AgentExecutionID != "execution-successor" { + t.Fatalf("LaunchSession response = %+v, want successor execution", response) + } + if launchCalls != 1 || !started { + t.Fatalf("launch calls = %d, started = %t, want one successful launch", launchCalls, started) + } + ensured, err := svc.EnsureSession(ctx, "task-reused", EnsureSessionOptions{ + ActivationSource: LaunchActivationSourceSessionOpen, + }) + if err != nil { + t.Fatalf("EnsureSession: %v", err) + } + if ensured.SessionID != "session-reused" || ensured.NewlyCreated { + t.Fatalf("EnsureSession response = %+v, want the existing session", ensured) + } + + sessions, err := repo.ListTaskSessions(ctx, "task-reused") + if err != nil { + t.Fatalf("list sessions after passive inspection: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("session count after passive inspection = %d, want 1", len(sessions)) + } +} + +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.2 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.5 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.9 +func TestSessionOpenRecoveryCapacityDisposition(t *testing.T) { + for _, test := range []struct { + name string + ceiling int + occupySlot bool + }{ + {name: "free capacity resumes selected sibling", ceiling: unlimitedSessionCeiling}, + {name: "full capacity waits without replacing queued sibling", ceiling: 1, occupySlot: true}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSessionOpenRecoveryState(t, repo, models.TaskSessionStateWaitingForInput) + createSessionOpenRecoverySession(t, repo, "session-primary", models.TaskSessionStateWaitingForInput, true) + createSessionOpenRecoverySession(t, repo, "session-queued", models.TaskSessionStateCreated, false) + + queued := models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + metaKeySessionID: "session-queued", + metaKeyAgentProfileID: "profile-queued", + "prompt": "queued prompt", + }, + Origin: string(launchOriginAutomatic), + QueuedAt: time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC), + }) + if err := repo.SetTaskMetadataKey(ctx, "task-reused", models.MetaKeyDeferredLaunch, queued); err != nil { + t.Fatalf("set queued sibling: %v", err) + } + beforeTask, err := repo.GetTask(ctx, "task-reused") + if err != nil { + t.Fatalf("load task before recovery: %v", err) + } + beforeQueue := beforeTask.Metadata[models.MetaKeyDeferredLaunch] + beforePrimary, err := repo.GetTaskSession(ctx, "session-primary") + if err != nil { + t.Fatalf("load primary session before recovery: %v", err) + } + + launchCalls := 0 + started := false + agentMgr := &sessionUpdatingAgentManager{ + mockAgentManager: &mockAgentManager{ + repoForExecutionLookup: repo, + launchAgentFunc: func(_ context.Context, _ *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + launchCalls++ + running, err := repo.GetExecutorRunningBySessionID(context.Background(), "session-reused") + if err != nil { + return nil, err + } + running.AgentExecutionID = "execution-capacity-successor" + if err := repo.UpsertExecutorRunning(context.Background(), running); err != nil { + return nil, err + } + return &executor.LaunchAgentResponse{ + AgentExecutionID: "execution-capacity-successor", + Status: v1.AgentStatusStarting, + }, nil + }, + }, + repo: repo, + sessionID: "session-reused", + taskID: "task-reused", + onStartCalled: &started, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + svc.sessionCeiling = newSessionCeilingController(test.ceiling, nil, nil) + if test.occupySlot { + decision := svc.sessionCeiling.admit(ctx, admissionRequest{ + taskID: "other-task", sessionID: "other-session", origin: launchOriginAutomatic, seam: "test", + }) + if !decision.admitted { + t.Fatalf("occupy test ceiling slot: %+v", decision) + } + } + + response, err := svc.LaunchSession(ctx, &LaunchSessionRequest{ + TaskID: "task-reused", + SessionID: "session-reused", + Intent: IntentResume, + ActivationSource: LaunchActivationSourceSessionOpen, + }) + if err != nil { + t.Fatalf("LaunchSession: %v", err) + } + if response == nil || !response.Success { + t.Fatalf("LaunchSession response = %+v, want success", response) + } + if test.occupySlot { + assertFullSessionOpenRecovery(t, response, launchCalls, started) + } else { + assertFreeSessionOpenRecovery(t, response, launchCalls, started) + } + + afterTask, err := repo.GetTask(ctx, "task-reused") + if err != nil { + t.Fatalf("load task after recovery: %v", err) + } + if !reflect.DeepEqual(afterTask.Metadata[models.MetaKeyDeferredLaunch], beforeQueue) { + t.Fatalf("queued sibling changed: before=%#v after=%#v", beforeQueue, afterTask.Metadata[models.MetaKeyDeferredLaunch]) + } + afterPrimary, err := repo.GetTaskSession(ctx, "session-primary") + if err != nil { + t.Fatalf("load primary session after recovery: %v", err) + } + if afterPrimary.IsPrimary != beforePrimary.IsPrimary { + t.Fatalf("primary session changed: before=%t after=%t", beforePrimary.IsPrimary, afterPrimary.IsPrimary) + } + selected, err := repo.GetTaskSession(ctx, "session-reused") + if err != nil { + t.Fatalf("load selected session after recovery: %v", err) + } + if selected.Metadata[ceilingManualOverrideMetadataKey] != nil { + t.Fatalf("session_open recovery recorded a manual override: %#v", selected.Metadata[ceilingManualOverrideMetadataKey]) + } + }) + } +} + +func assertFullSessionOpenRecovery(t *testing.T, response *LaunchSessionResponse, launchCalls int, started bool) { + t.Helper() + if response.SessionID != "session-reused" || + response.ActivationDisposition != activationDispositionSuppressed || + response.ActivationReason != "session_capacity" || response.AgentExecutionID != "" { + t.Fatalf("full-capacity response = %+v, want selected session suppressed for capacity", response) + } + if launchCalls != 0 || started { + t.Fatalf("full-capacity launch calls = %d, started = %t, want no runtime launch", launchCalls, started) + } +} + +func assertFreeSessionOpenRecovery(t *testing.T, response *LaunchSessionResponse, launchCalls int, started bool) { + t.Helper() + if response.AgentExecutionID != "execution-capacity-successor" { + t.Fatalf("free-capacity response = %+v, want successor execution", response) + } + if launchCalls != 1 || !started { + t.Fatalf("free-capacity launch calls = %d, started = %t, want one launch", launchCalls, started) + } +} + +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.2 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.6 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.6 +func TestSessionOpenRecoveryRechecksOwnership(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSessionOpenRecoveryState(t, repo, models.TaskSessionStateWaitingForInput) + launchCalls := 0 + agentMgr := &mockAgentManager{ + repoForExecutionLookup: repo, + launchAgentFunc: func(_ context.Context, _ *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + launchCalls++ + return &executor.LaunchAgentResponse{AgentExecutionID: "should-not-launch"}, nil + }, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + svc.sessionCeiling = newSessionCeilingController(unlimitedSessionCeiling, nil, nil) + + status, err := svc.GetTaskSessionStatus(ctx, "task-reused", "session-reused") + if err != nil { + t.Fatalf("GetTaskSessionStatus: %v", err) + } + if !status.AutoResumeAllowed { + t.Fatalf("initial status = %+v, want recovery allowed", status) + } + if passive := svc.passiveLaunchResponse(ctx, &LaunchSessionRequest{ + TaskID: "task-reused", + SessionID: "session-reused", + Intent: IntentResume, + ActivationSource: LaunchActivationSourceSessionOpen, + }, IntentResume); passive != nil { + t.Fatalf("initial passive response = %+v, want recovery allowed", passive) + } + + queued := models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + metaKeySessionID: "session-reused", + metaKeyAgentProfileID: "profile-reused", + }, + Origin: string(launchOriginAutomatic), + QueuedAt: time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC), + }) + barrier := &sessionOpenTaskReadBarrierStore{ + sessionExecutorStore: svc.repo, + taskID: "task-reused", + entered: make(chan struct{}), + release: make(chan struct{}), + } + svc.repo = barrier + resultCh := make(chan struct { + response *LaunchSessionResponse + err error + }, 1) + go func() { + response, launchErr := svc.LaunchSession(ctx, &LaunchSessionRequest{ + TaskID: "task-reused", + SessionID: "session-reused", + Intent: IntentResume, + ActivationSource: LaunchActivationSourceSessionOpen, + }) + resultCh <- struct { + response *LaunchSessionResponse + err error + }{response: response, err: launchErr} + }() + select { + case <-barrier.entered: + case <-time.After(5 * time.Second): + t.Fatal("LaunchSession did not reach the post-read eligibility barrier") + } + if err := repo.SetTaskMetadataKey(ctx, "task-reused", models.MetaKeyDeferredLaunch, queued); err != nil { + t.Fatalf("set successor queue during launch: %v", err) + } + queuedTask, err := repo.GetTask(ctx, "task-reused") + if err != nil { + t.Fatalf("reload successor queue during launch: %v", err) + } + queuedBeforeRelease := queuedTask.Metadata[models.MetaKeyDeferredLaunch] + close(barrier.release) + var result struct { + response *LaunchSessionResponse + err error + } + select { + case result = <-resultCh: + case <-time.After(5 * time.Second): + t.Fatal("LaunchSession did not finish after releasing the eligibility barrier") + } + response, err := result.response, result.err + if err != nil { + t.Fatalf("LaunchSession after successor queue: %v", err) + } + if response == nil || response.ActivationDisposition != activationDispositionQueued || + response.ActivationReason != autoResumeBlockedLaunchQueued { + t.Fatalf("guarded passive launch response = %+v, want queued", response) + } + if launchCalls != 0 { + t.Fatalf("runtime launch calls = %d, want none after queue ownership changed", launchCalls) + } + + task, err := repo.GetTask(ctx, "task-reused") + if err != nil { + t.Fatalf("reload task after guarded launch: %v", err) + } + record, ok := task.Metadata[models.MetaKeyDeferredLaunch].(map[string]interface{}) + if !ok { + t.Fatalf("guarded passive launch deferred record = %#v, want object", task.Metadata[models.MetaKeyDeferredLaunch]) + } + deferred, err := models.ReadCeilingDeferral(record) + if err != nil || models.CeilingDeferralSessionID(task, deferred) != "session-reused" { + t.Fatalf("guarded passive launch changed deferred record: %#v", task.Metadata[models.MetaKeyDeferredLaunch]) + } + if !reflect.DeepEqual(record, queuedBeforeRelease) { + t.Fatalf("guarded passive launch mutated queued record: before=%#v after=%#v", queuedBeforeRelease, record) + } +} + +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.6 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.6 +func TestSessionOpenRecoveryDelayedCallbacks(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSessionOpenRecoveryState(t, repo, models.TaskSessionStateWaitingForInput) + createSessionOpenRecoverySession(t, repo, "session-primary", models.TaskSessionStateWaitingForInput, true) + createSessionOpenRecoverySession(t, repo, "session-queued", models.TaskSessionStateCreated, false) + queued := models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + metaKeySessionID: "session-queued", + metaKeyAgentProfileID: "profile-queued", + "prompt": "queued prompt", + }, + Origin: string(launchOriginAutomatic), + QueuedAt: time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC), + }) + if err := repo.SetTaskMetadataKey(ctx, "task-reused", models.MetaKeyDeferredLaunch, queued); err != nil { + t.Fatalf("set accepted successor queue: %v", err) + } + beforeTask, err := repo.GetTask(ctx, "task-reused") + if err != nil { + t.Fatalf("load task before recovery: %v", err) + } + beforeRoute := beforeTask.Metadata[models.MetaKeyWorkflowSessionRoute] + beforeQueue := beforeTask.Metadata[models.MetaKeyDeferredLaunch] + beforePrimary, err := repo.GetTaskSession(ctx, "session-primary") + if err != nil { + t.Fatalf("load primary session before recovery: %v", err) + } + + started := false + agentMgr := &sessionUpdatingAgentManager{ + mockAgentManager: &mockAgentManager{ + repoForExecutionLookup: repo, + launchAgentFunc: func(_ context.Context, _ *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + running, err := repo.GetExecutorRunningBySessionID(context.Background(), "session-reused") + if err != nil { + return nil, err + } + running.AgentExecutionID = "execution-successor" + if err := repo.UpsertExecutorRunning(context.Background(), running); err != nil { + return nil, err + } + return &executor.LaunchAgentResponse{ + AgentExecutionID: "execution-successor", + Status: v1.AgentStatusStarting, + }, nil + }, + }, + repo: repo, + sessionID: "session-reused", + taskID: "task-reused", + onStartCalled: &started, + } + service := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + service.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + service.sessionCeiling = newSessionCeilingController(unlimitedSessionCeiling, nil, nil) + service.turnService = &repoTurnService{repo: repo} + + response, err := service.LaunchSession(ctx, &LaunchSessionRequest{ + TaskID: "task-reused", + SessionID: "session-reused", + Intent: IntentResume, + ActivationSource: LaunchActivationSourceSessionOpen, + }) + if err != nil { + t.Fatalf("LaunchSession: %v", err) + } + if response == nil || response.AgentExecutionID != "execution-successor" || !started { + t.Fatalf("successor launch response = %+v, started = %t", response, started) + } + if running, err := repo.GetExecutorRunningBySessionID(ctx, "session-reused"); err != nil { + t.Fatalf("load successor executor row: %v", err) + } else if running == nil || running.AgentExecutionID != "execution-successor" { + t.Fatalf("successor executor row = %+v, want execution-successor", running) + } + if err := repo.CreateTurn(ctx, &models.Turn{ + ID: "turn-successor", + TaskID: "task-reused", + TaskSessionID: "session-reused", + StartedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("create successor turn: %v", err) + } + if err := repo.UpdateTaskSessionState(ctx, "session-reused", models.TaskSessionStateRunning, ""); err != nil { + t.Fatalf("mark successor session running: %v", err) + } + + event := watcher.AgentEventData{ + TaskID: "task-reused", + SessionID: "session-reused", + AgentExecutionID: "execution-reused", + } + service.handleAgentStopped(ctx, event) + service.handleAgentStopped(ctx, event) + service.handleAgentCompleted(ctx, event) + service.handleAgentCompleted(ctx, event) + + restartedAgentMgr := &mockAgentManager{repoForExecutionLookup: repo} + restarted := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), restartedAgentMgr) + restarted.executor = executor.NewExecutor(restartedAgentMgr, repo, testLogger(), executor.ExecutorConfig{}) + restarted.turnService = &repoTurnService{repo: repo} + restarted.handleAgentStopped(ctx, event) + restarted.handleAgentCompleted(ctx, event) + restarted.handleAgentStopped(ctx, event) + restarted.handleAgentCompleted(ctx, event) + + after, err := repo.GetTaskSession(ctx, "session-reused") + if err != nil { + t.Fatalf("load selected session after delayed callbacks: %v", err) + } + if after.State != models.TaskSessionStateRunning { + t.Fatalf("successor session state after delayed callbacks = %q, want RUNNING", after.State) + } + stop, ok := workflowProfileSwitchStopIntentFromMetadata(after.Metadata) + if !ok || !stop.Consumed || stop.ExecutionID != "execution-reused" { + t.Fatalf("stop intent after delayed callbacks = %#v, want consumed old-execution tombstone", after.Metadata[models.SessionMetaKeyWorkflowProfileSwitchStopIntent]) + } + activeTurn, err := repo.GetActiveTurnBySessionID(ctx, "session-reused") + if err != nil || activeTurn == nil || activeTurn.ID != "turn-successor" { + t.Fatalf("active successor turn = %+v, err=%v, want turn-successor", activeTurn, err) + } + if open := openTurnCount(t, repo, "session-reused"); open != 1 { + t.Fatalf("open successor turns = %d, want 1", open) + } + running, err := repo.GetExecutorRunningBySessionID(ctx, "session-reused") + if err != nil { + t.Fatalf("load executor row after delayed callbacks: %v", err) + } + if running == nil || running.AgentExecutionID != "execution-successor" { + t.Fatalf("executor row after delayed callbacks = %+v, want successor execution", running) + } + primary, err := repo.GetTaskSession(ctx, "session-primary") + if err != nil { + t.Fatalf("load primary session after delayed callbacks: %v", err) + } + if primary.IsPrimary != beforePrimary.IsPrimary { + t.Fatalf("primary session changed after delayed callbacks: before=%t after=%t", beforePrimary.IsPrimary, primary.IsPrimary) + } + afterTask, err := repo.GetTask(ctx, "task-reused") + if err != nil { + t.Fatalf("load task after delayed callbacks: %v", err) + } + if !reflect.DeepEqual(afterTask.Metadata[models.MetaKeyWorkflowSessionRoute], beforeRoute) { + t.Fatalf("workflow route changed after delayed callbacks: before=%#v after=%#v", beforeRoute, afterTask.Metadata[models.MetaKeyWorkflowSessionRoute]) + } + if !reflect.DeepEqual(afterTask.Metadata[models.MetaKeyDeferredLaunch], beforeQueue) { + t.Fatalf("accepted queue changed after delayed callbacks: before=%#v after=%#v", beforeQueue, afterTask.Metadata[models.MetaKeyDeferredLaunch]) + } + + allowed, reason := restarted.autoResumeEligibility(ctx, afterTask, after) + if !allowed || reason != "" { + t.Fatalf("eligibility after delayed callbacks = %t, %q; want allowed", allowed, reason) + } +} + +func createSessionOpenRecoverySession( + t *testing.T, + repo *sqliterepo.Repository, + sessionID string, + state models.TaskSessionState, + primary bool, +) { + t.Helper() + now := time.Now().UTC() + if err := repo.CreateTaskSession(context.Background(), &models.TaskSession{ + ID: sessionID, + TaskID: "task-reused", + State: state, + IsPrimary: primary, + StartedAt: now, + UpdatedAt: now, + AgentProfileID: "profile-" + sessionID, + }); err != nil { + t.Fatalf("create session %s: %v", sessionID, err) + } +} + +type sessionOpenTaskReadBarrierStore struct { + sessionExecutorStore + taskID string + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (s *sessionOpenTaskReadBarrierStore) GetTask( + ctx context.Context, + id string, +) (*models.Task, error) { + task, err := s.sessionExecutorStore.GetTask(ctx, id) + if id == s.taskID { + s.once.Do(func() { + close(s.entered) + <-s.release + }) + } + return task, err +} diff --git a/apps/backend/internal/orchestrator/session_open_recovery_test.go b/apps/backend/internal/orchestrator/session_open_recovery_test.go new file mode 100644 index 0000000000..9181945689 --- /dev/null +++ b/apps/backend/internal/orchestrator/session_open_recovery_test.go @@ -0,0 +1,374 @@ +package orchestrator + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/agentruntime" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/task/models" + repository "github.com/kandev/kandev/internal/task/repository" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.4 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.7 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.8 +func TestSessionOpenRecoveryEligibility(t *testing.T) { + route := models.WorkflowSessionRoute{ + OperationID: "route-reused", + DestinationStepID: "step-current", + EntryIdentity: "entry:00000000000000000001", + TargetKind: "new_session", + AgentProfileID: "profile-reused", + DestinationID: "session-reused", + Phase: workflowSessionRouteCommitted, + } + + tests := []struct { + name string + taskMetadata map[string]interface{} + sessionMeta map[string]interface{} + wantAllowed bool + }{ + { + name: "reused destination ignores consumed historical stop", + taskMetadata: map[string]interface{}{ + models.MetaKeyWorkflowSessionRoute: route, + }, + sessionMeta: map[string]interface{}{ + models.SessionMetaKeyWorkflowProfileSwitchStopIntent: models.WorkflowProfileSwitchStopIntent{ + ExecutionID: "execution-old", + Stamp: "stop-old", + Consumed: true, + }, + }, + wantAllowed: true, + }, + { + name: "settled empty ceiling record has no pending work", + taskMetadata: map[string]interface{}{ + models.MetaKeyDeferredLaunch: map[string]interface{}{}, + }, + wantAllowed: true, + }, + { + name: "reused destination with settled empty ceiling record", + taskMetadata: map[string]interface{}{ + models.MetaKeyWorkflowSessionRoute: route, + models.MetaKeyDeferredLaunch: map[string]interface{}{}, + }, + sessionMeta: map[string]interface{}{ + models.SessionMetaKeyWorkflowProfileSwitchStopIntent: models.WorkflowProfileSwitchStopIntent{ + ExecutionID: "execution-old", + Stamp: "stop-old", + Consumed: true, + }, + }, + wantAllowed: true, + }, + { + name: "absent deferred record remains eligible", + wantAllowed: true, + }, + { + name: "null deferred record remains eligible", + taskMetadata: map[string]interface{}{ + models.MetaKeyDeferredLaunch: nil, + }, + wantAllowed: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + task := &models.Task{ + ID: "task-reused", + WorkflowStepID: "step-current", + Metadata: tt.taskMetadata, + } + session := &models.TaskSession{ + ID: "session-reused", + TaskID: "task-reused", + AgentProfileID: "profile-reused", + IsPrimary: true, + Metadata: tt.sessionMeta, + } + + allowed, reason := (&Service{}).autoResumeEligibility(context.Background(), task, session) + if allowed != tt.wantAllowed { + t.Fatalf("allowed = %t, want %t (reason %q)", allowed, tt.wantAllowed, reason) + } + }) + } +} + +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.2 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.5 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.6 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.9 +func TestSessionOpenRecoveryOwnership(t *testing.T) { + queuedRecord := func(sessionID string) map[string]interface{} { + return models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + metaKeySessionID: sessionID, + metaKeyAgentProfileID: "profile-reused", + }, + Origin: string(launchOriginAutomatic), + QueuedAt: time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC), + }) + } + consumedStop := models.WorkflowProfileSwitchStopIntent{ + ExecutionID: "execution-old", + Stamp: "stop-old", + Consumed: true, + } + build := func() (*models.Task, *models.TaskSession) { + return &models.Task{ + ID: "task-reused", + WorkflowStepID: "step-current", + }, &models.TaskSession{ + ID: "session-reused", + TaskID: "task-reused", + AgentProfileID: "profile-reused", + } + } + tests := []struct { + name string + mutate func(*models.Task, *models.TaskSession) + wantAllowed bool + wantBlockCode string + }{ + { + name: "current parking marker does not block recovery", + mutate: func(_ *models.Task, session *models.TaskSession) { + session.Metadata = map[string]interface{}{ + models.SessionMetaKeyWorkflowParking: models.WorkflowParking{ + Stamp: "parking-current", SourceSessionID: session.ID, + }, + } + }, + wantAllowed: true, + }, + { + name: "historical stop intent does not block recovery", + mutate: func(_ *models.Task, session *models.TaskSession) { + session.Metadata = map[string]interface{}{ + models.SessionMetaKeyWorkflowProfileSwitchStopIntent: consumedStop, + } + }, + wantAllowed: true, + }, + { + name: "malformed parking metadata does not block recovery", + mutate: func(_ *models.Task, session *models.TaskSession) { + session.Metadata = map[string]interface{}{ + models.SessionMetaKeyWorkflowParking: map[string]interface{}{"stamp": "legacy"}, + } + }, + wantAllowed: true, + }, + { + name: "current queued destination remains owned", + mutate: func(task *models.Task, _ *models.TaskSession) { + task.Metadata = map[string]interface{}{ + models.MetaKeyWorkflowSessionRoute: routeForTestSessionOpenRecovery(), + models.MetaKeyDeferredLaunch: queuedRecord("session-reused"), + } + }, + wantBlockCode: autoResumeBlockedLaunchQueued, + }, + { + name: "malformed deferred ownership remains blocked", + mutate: func(task *models.Task, _ *models.TaskSession) { + task.Metadata = map[string]interface{}{ + models.MetaKeyDeferredLaunch: map[string]interface{}{"wip_task_id": "other-task"}, + } + }, + wantBlockCode: autoResumeBlockedOwnershipUnavailable, + }, + { + name: "queued sibling does not block selected recovery", + mutate: func(task *models.Task, _ *models.TaskSession) { + task.Metadata = map[string]interface{}{ + models.MetaKeyDeferredLaunch: queuedRecord("session-other"), + } + }, + wantAllowed: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + task, session := build() + test.mutate(task, session) + allowed, reason := (&Service{}).autoResumeEligibility(context.Background(), task, session) + if allowed != test.wantAllowed || reason != test.wantBlockCode { + t.Fatalf("eligibility = %t, %q; want %t, %q", allowed, reason, test.wantAllowed, test.wantBlockCode) + } + }) + } +} + +func routeForTestSessionOpenRecovery() models.WorkflowSessionRoute { + return models.WorkflowSessionRoute{ + OperationID: "route-reused", + DestinationStepID: "step-current", + EntryIdentity: "entry:00000000000000000001", + TargetKind: "new_session", + AgentProfileID: "profile-reused", + DestinationID: "session-reused", + Phase: workflowSessionRouteCommitted, + } +} + +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.4 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.7 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.8 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.4 +func TestSessionOpenRecoveryAfterRestart(t *testing.T) { + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "session-open-recovery.db") + repo, closeRepo := openSessionOpenRecoveryRepo(t, dbPath) + seedSessionOpenRecoveryState(t, repo, models.TaskSessionStateRunning) + closeRepo() + + restartedRepo, _ := openSessionOpenRecoveryRepo(t, dbPath) + taskRepo := newMockTaskRepo() + taskRepo.tasks["task-reused"] = &v1.Task{ID: "task-reused", State: v1.TaskStateInProgress} + agentMgr := &mockAgentManager{repoForExecutionLookup: restartedRepo} + svc := createTestServiceWithAgent(restartedRepo, newMockStepGetter(), taskRepo, agentMgr) + svc.executor = executor.NewExecutor(agentMgr, restartedRepo, testLogger(), executor.ExecutorConfig{}) + svc.reconcileExecutorSessionsOnStartup(ctx) + + session, err := restartedRepo.GetTaskSession(ctx, "session-reused") + if err != nil { + t.Fatalf("load session after restart: %v", err) + } + if session.State != models.TaskSessionStateWaitingForInput { + t.Fatalf("session state after restart = %q, want WAITING_FOR_INPUT", session.State) + } + if parking, ok := models.LoadWorkflowParking(session.Metadata); !ok || parking.Stamp == "" { + t.Fatalf("workflow parking after restart = %#v, want retained marker", session.Metadata[models.SessionMetaKeyWorkflowParking]) + } + stop, ok := workflowProfileSwitchStopIntentFromMetadata(session.Metadata) + if !ok || !stop.Consumed { + t.Fatalf("stop intent after restart = %#v, want retained consumed tombstone", session.Metadata[models.SessionMetaKeyWorkflowProfileSwitchStopIntent]) + } + + task, err := restartedRepo.GetTask(ctx, "task-reused") + if err != nil { + t.Fatalf("load task after restart: %v", err) + } + deferred, ok := task.Metadata[models.MetaKeyDeferredLaunch].(map[string]interface{}) + if !ok || len(deferred) != 0 { + t.Fatalf("deferred launch after restart = %#v, want settled empty record", task.Metadata[models.MetaKeyDeferredLaunch]) + } + running, err := restartedRepo.GetExecutorRunningBySessionID(ctx, "session-reused") + if err != nil { + t.Fatalf("load executor row after restart: %v", err) + } + if running.ResumeToken != "resume-reused" || !running.Resumable { + t.Fatalf("executor row after restart = %+v, want retained resumable token", running) + } + + status, err := svc.GetTaskSessionStatus(ctx, "task-reused", "session-reused") + if err != nil { + t.Fatalf("GetTaskSessionStatus after restart: %v", err) + } + if !status.AutoResumeAllowed || !status.NeedsResume { + t.Fatalf("status after restart = %+v, want passive recovery", status) + } +} + +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.2 +// @covers AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.5 +func openSessionOpenRecoveryRepo(t *testing.T, dbPath string) (*sqliterepo.Repository, func()) { + t.Helper() + dbConn, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open session recovery database: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repo, cleanup, err := repository.Provide(sqlxDB, sqlxDB, nil) + if err != nil { + _ = sqlxDB.Close() + t.Fatalf("create session recovery repository: %v", err) + } + closed := false + closeRepo := func() { + if closed { + return + } + closed = true + _ = cleanup() + _ = sqlxDB.Close() + } + t.Cleanup(closeRepo) + return repo, closeRepo +} + +func seedSessionOpenRecoveryState(t *testing.T, repo *sqliterepo.Repository, state models.TaskSessionState) { + t.Helper() + ctx := context.Background() + seedTaskAndSession(t, repo, "task-reused", "session-reused", state) + + task, err := repo.GetTask(ctx, "task-reused") + if err != nil { + t.Fatalf("load task while seeding recovery state: %v", err) + } + task.WorkflowStepID = "step-current" + task.Metadata = map[string]interface{}{ + models.MetaKeyWorkflowSessionRoute: routeForTestSessionOpenRecovery(), + models.MetaKeyDeferredLaunch: map[string]interface{}{}, + } + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("save task recovery state: %v", err) + } + + session, err := repo.GetTaskSession(ctx, "session-reused") + if err != nil { + t.Fatalf("load session while seeding recovery state: %v", err) + } + session.AgentProfileID = "profile-reused" + session.IsPrimary = false + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("save session recovery state: %v", err) + } + parking := models.WorkflowParking{ + Stamp: "parking-reused", + ParkedAt: time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC), + SourceSessionID: session.ID, + } + if err := repo.SetSessionMetadataKey(ctx, session.ID, models.SessionMetaKeyWorkflowParking, parking); err != nil { + t.Fatalf("save parking recovery state: %v", err) + } + if err := repo.SetSessionMetadataKey(ctx, session.ID, models.SessionMetaKeyWorkflowProfileSwitchStopIntent, models.WorkflowProfileSwitchStopIntent{ + ExecutionID: "execution-reused", + Stamp: parking.Stamp, + Consumed: true, + }); err != nil { + t.Fatalf("save stop intent recovery state: %v", err) + } + + now := time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC) + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: session.ID, + SessionID: session.ID, + TaskID: task.ID, + Runtime: agentruntime.RuntimeStandalone, + Status: models.ExecutorRunningStatusRunning, + Resumable: true, + ResumeToken: "resume-reused", + AgentExecutionID: "execution-reused", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("save executor recovery state: %v", err) + } +} diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 07f8319e7f..d4f541b721 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -2989,7 +2989,21 @@ func (s *Service) resumeTaskSessionWithContinuation( if isOfficeTask { return nil, decorateResumeFailure(errOfficeTaskResumeRequiresScheduler) } - seam4Res, deferred, err := s.admitOrDeferSeam4(ctx, taskID, sessionID, launchOrigin(options.Origin), seam4ResumePayloadWithBinding(sessionID, options, entryBinding)) + admissionCtx := ctx + releaseAdmission := func() {} + if isSessionOpenRecoveryContext(ctx) { + admissionCtx, releaseAdmission = s.lockCeilingEntryAdmission(ctx, taskID) + } + seam4Res, deferred, err := func() (*sessionKeyedCeilingReservation, bool, error) { + defer releaseAdmission() + if isSessionOpenRecoveryContext(ctx) { + if reason := s.sessionOpenRecoveryBlockReason(admissionCtx, taskID, session); reason != "" { + return nil, false, &sessionOpenRecoveryBlockedError{reason: reason} + } + } + return s.admitOrDeferSeam4(admissionCtx, taskID, sessionID, launchOrigin(options.Origin), + seam4ResumePayloadWithBinding(sessionID, options, entryBinding)) + }() if err != nil { return nil, err } @@ -4182,15 +4196,33 @@ func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID st } const ( - autoResumeBlockedWorkflowParked = "workflow_parked" autoResumeBlockedLaunchQueued = "launch_queued" autoResumeBlockedOwnershipUnavailable = "ownership_unavailable" ) -// autoResumeEligibility is intentionally conservative. Passive inspection may -// resume only a session whose ownership markers are absent or valid and whose -// durable launch does not belong to another session. A malformed marker blocks -// recovery instead of falling back to a fresh launch. +func (s *Service) sessionOpenRecoveryBlockReason( + ctx context.Context, + taskID string, + session *models.TaskSession, +) string { + if s == nil || s.repo == nil || session == nil { + return autoResumeBlockedOwnershipUnavailable + } + task, err := s.repo.GetTask(ctx, taskID) + if err != nil || task == nil { + return autoResumeBlockedOwnershipUnavailable + } + allowed, reason := s.autoResumeEligibility(ctx, task, session) + if allowed { + return "" + } + return reason +} + +// autoResumeEligibility is intentionally conservative about deferred launches. +// Passive inspection may resume a session unless a valid durable launch belongs +// to that exact session. A malformed deferred record blocks recovery instead of +// falling back to a fresh launch. // //nolint:cyclop // Passive recovery checks each ownership source independently. func (s *Service) autoResumeEligibility( @@ -4199,26 +4231,6 @@ func (s *Service) autoResumeEligibility( if session == nil || task == nil { return false, autoResumeBlockedOwnershipUnavailable } - if raw, present := session.Metadata[models.SessionMetaKeyWorkflowParking]; present { - if _, ok := models.LoadWorkflowParking(session.Metadata); ok { - return false, autoResumeBlockedWorkflowParked - } - if raw != nil { - return false, autoResumeBlockedOwnershipUnavailable - } - } - // Sessions written before workflow_parking was introduced can still carry - // the stamped stop-intent tombstone. Treat it as parked only when the - // committed route identifies this exact non-primary session as the source. - // Otherwise ownership is ambiguous and passive recovery must remain read-only. - if _, ok := workflowProfileSwitchStopIntentFromMetadata(session.Metadata); ok { - route, routeOK := models.LoadWorkflowSessionRoute(task.Metadata) - if routeOK && !session.IsPrimary && route.SourceSessionID == session.ID && - route.DestinationID != "" && route.DestinationID != session.ID { - return false, autoResumeBlockedWorkflowParked - } - return false, autoResumeBlockedOwnershipUnavailable - } raw, present := task.Metadata[models.MetaKeyDeferredLaunch] if !present || raw == nil { return true, "" @@ -4227,6 +4239,9 @@ func (s *Service) autoResumeEligibility( if !ok { return false, autoResumeBlockedOwnershipUnavailable } + if len(record) == 0 { + return true, "" + } deferral, err := models.ReadCeilingDeferral(record) if err != nil { return false, autoResumeBlockedOwnershipUnavailable diff --git a/apps/backend/internal/orchestrator/task_operations_resume_test.go b/apps/backend/internal/orchestrator/task_operations_resume_test.go index 20130d4dd4..67ffd0d0ea 100644 --- a/apps/backend/internal/orchestrator/task_operations_resume_test.go +++ b/apps/backend/internal/orchestrator/task_operations_resume_test.go @@ -111,7 +111,7 @@ func TestGetTaskSessionStatus_AutoResumesNormalWaitingSession(t *testing.T) { } } -func TestAutoResumeEligibilityPreservesSessionOwnership(t *testing.T) { +func TestAutoResumeEligibilityPreservesDeferredLaunchOwnership(t *testing.T) { queuedRecord := models.CeilingRecordKeys(models.CeilingDeferral{ Kind: models.CeilingLaunchStartCreated, Payload: map[string]interface{}{ @@ -143,20 +143,20 @@ func TestAutoResumeEligibilityPreservesSessionOwnership(t *testing.T) { wantAllowed: true, }, { - name: "durable parking blocks source session", + name: "durable parking does not block source session", sessionID: "parked-session", sessionMeta: map[string]interface{}{models.SessionMetaKeyWorkflowParking: models.WorkflowParking{ Stamp: "parking-1", ParkedAt: time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC), SourceSessionID: "parked-session", }}, - wantBlockCode: autoResumeBlockedWorkflowParked, + wantAllowed: true, }, { - name: "malformed parking blocks conservatively", - sessionID: "parked-session", - sessionMeta: map[string]interface{}{models.SessionMetaKeyWorkflowParking: map[string]interface{}{"stamp": "parking-1"}}, - wantBlockCode: autoResumeBlockedOwnershipUnavailable, + name: "malformed parking does not block recovery", + sessionID: "parked-session", + sessionMeta: map[string]interface{}{models.SessionMetaKeyWorkflowParking: map[string]interface{}{"stamp": "parking-1"}}, + wantAllowed: true, }, { name: "queued destination is not passively resumed", @@ -200,24 +200,24 @@ func TestAutoResumeEligibilityPreservesSessionOwnership(t *testing.T) { wantBlockCode: autoResumeBlockedOwnershipUnavailable, }, { - name: "exact legacy route identifies parked source", + name: "exact legacy route does not block parked source", taskMetadata: map[string]interface{}{models.MetaKeyWorkflowSessionRoute: legacyRoute}, sessionID: "parked-session", sessionMeta: map[string]interface{}{models.SessionMetaKeyWorkflowProfileSwitchStopIntent: models.WorkflowProfileSwitchStopIntent{ ExecutionID: "execution-1", Stamp: "legacy-1", }}, - wantBlockCode: autoResumeBlockedWorkflowParked, + wantAllowed: true, }, { - name: "legacy stop without exact route is ambiguous", + name: "legacy stop without exact route does not block recovery", taskMetadata: map[string]interface{}{models.MetaKeyWorkflowSessionRoute: legacyRoute}, sessionID: "other-session", sessionMeta: map[string]interface{}{models.SessionMetaKeyWorkflowProfileSwitchStopIntent: models.WorkflowProfileSwitchStopIntent{ ExecutionID: "execution-1", Stamp: "legacy-1", }}, - wantBlockCode: autoResumeBlockedOwnershipUnavailable, + wantAllowed: true, }, } diff --git a/docs/decisions/2026-09-16-passive-session-inspection.md b/docs/decisions/2026-09-16-passive-session-inspection.md index 8bb2ee47d2..4f67db80a4 100644 --- a/docs/decisions/2026-09-16-passive-session-inspection.md +++ b/docs/decisions/2026-09-16-passive-session-inspection.md @@ -1,9 +1,13 @@ # ADR-2026-09-16-passive-session-inspection: Keep inspection separate from execution ownership -**Status:** proposed +**Status:** superseded in part by [ADR-2026-09-18-session-open-resumes-conversation](2026-09-18-session-open-resumes-conversation.md) **Date:** 2026-09-16 **Area:** workflow +The successor replaces parking-based suppression and the parked-session note. +Queue ownership, automatic admission, and reconciliation constraints remain. +The text below records the original decision. + ## Context Workflow parking and process loss both appear as resumable sessions without a diff --git a/docs/decisions/2026-09-18-session-open-resumes-conversation.md b/docs/decisions/2026-09-18-session-open-resumes-conversation.md new file mode 100644 index 0000000000..7857504275 --- /dev/null +++ b/docs/decisions/2026-09-18-session-open-resumes-conversation.md @@ -0,0 +1,63 @@ +# ADR-2026-09-18-session-open-resumes-conversation: Opening resumes the selected conversation + +**Status:** accepted +**Date:** 2026-09-18 +**Area:** workflow + +## Context + +Workflow switches stop earlier sessions and preserve their conversations. +The previous inspection policy exposed a parked-session banner and prevented +open-time recovery. Users had to understand workflow parking before continuing +a conversation. The user explicitly rejected that interaction on 2026-09-18. + +## Decision + +Opening or selecting a workflow-stopped conversation uses normal automatic +session recovery. Current or historical workflow parking is not an eligibility +restriction. The interface has no parked-session banner, toolbar, or replacement +parking explanation. + +Existing auto-start prevention, automatic capacity admission, terminal-session +rules, authorization, and archive rules still apply. Session opening resumes +provider context; it does not send a new prompt or replay an interrupted turn. + +Conversation activation does not transfer workflow ownership. A selected older +session cannot become the primary workflow recipient, consume a destination's +queued prompt, or replace its deferred launch merely because the user opens it. +A queued destination remains scheduler-owned. A different conversation can +recover when capacity permits without altering that accepted launch. + +Keep execution-stamped stop tombstones for delayed callbacks. Legacy +`workflow_parking` metadata can remain stored but does not control recovery or +presentation. Remove its policy-only readers and writers when they have no +remaining lifecycle purpose; no bulk live-data rewrite is required. + +## Consequences + +The user sees ordinary conversations and normal recovery behavior. Opening an +older conversation can consume automatic capacity. It never gets a manual +ceiling override. If a task already owns a deferred launch, a capacity refusal +must preserve that record rather than enqueue conflicting work. + +This supersedes the parking-based suppression and parked-note portions of +[the previous inspection decision](2026-09-16-passive-session-inspection.md). +Its exact-recipient queue ownership, admission, and reconciliation rules remain. +The narrow historical-stop exception in the earlier repair plan is replaced by +this general policy. No primary-session or committed-route exception is needed +solely to disregard parking. + +## Alternatives Considered + +- Keep the banner and require explicit Resume: rejected by the user. +- Hide the banner but retain suppression: leaves the reported interaction defect. +- Resume only the current workflow destination: does not meet the user's request + to resume even a parked conversation when opened. +- Transfer workflow ownership on open: conflates conversation access with workflow routing. +- Ignore capacity or delete stop tombstones: breaks independent runtime protections. + +## Related records + +- [Requirements](../specs/tasks/requirements/queued-session-ownership.md). +- [Design](../specs/tasks/system-design/queued-session-ownership.md). +- [Revised implementation plan](../plans/session-open-recovery-eligibility/plan.md). diff --git a/docs/plans/queued-session-ownership/plan.md b/docs/plans/queued-session-ownership/plan.md index bc8646d999..ae069567cd 100644 --- a/docs/plans/queued-session-ownership/plan.md +++ b/docs/plans/queued-session-ownership/plan.md @@ -12,6 +12,15 @@ legacy_specs: [] # Implementation plan: Queued session ownership +## Policy supersession, 2026-09-18 + +The [revised conversation recovery package](../session-open-recovery-eligibility/plan.md) +supersedes parked-session suppression and parking-note presentation in this +historical package. Opening an earlier conversation now follows normal recovery. +Keep queue identity, admission, callback, and reconciliation coverage. Replace +old no-resume and parked-note assertions in the revised package's work orders. +Historical results and outstanding PostgreSQL checks below are unchanged. + ## Overview Keep Luna queued when a workflow enters Implement, keep the parked Astra @@ -297,3 +306,11 @@ Implementation and available rendered verification on 2026-09-17: - Projection refreshes can accidentally count as user activity and reorder tasks. - Legacy ambiguous ownership must surface recovery without dispatch or data loss. - The unexplained warning must not cause blanket suppression of genuine empty turns. + +## Follow-up: Session-open recovery eligibility + +The [recovery eligibility repair](../session-open-recovery-eligibility/plan.md) +owns the historical-stop and settled-deferral regressions found after restart. +It replaces the parking suppression policy and removes the parking note, with +separate and combined recovery cases on desktop and phone. Existing results and PostgreSQL prerequisites here +remain unchanged. The follow-up work order is pending implementation. diff --git a/docs/plans/queued-session-ownership/task-01-preserve-parked-sessions.md b/docs/plans/queued-session-ownership/task-01-preserve-parked-sessions.md index c087a7924e..b9be6f6cfd 100644 --- a/docs/plans/queued-session-ownership/task-01-preserve-parked-sessions.md +++ b/docs/plans/queued-session-ownership/task-01-preserve-parked-sessions.md @@ -22,6 +22,15 @@ system_design: # Task 01: Preserve parked sessions during inspection +## Policy supersession, 2026-09-18 + +The [revised conversation recovery package](../session-open-recovery-eligibility/plan.md) +supersedes parked-session suppression and parking-note presentation in this +historical package. Opening an earlier conversation now follows normal recovery. +Keep queue identity, admission, callback, and reconciliation coverage. Replace +old no-resume and parked-note assertions in the revised package's work orders. +Historical results and outstanding PostgreSQL checks below are unchanged. + ## Summary Make workflow parking durable and keep passive inspection separate from explicit @@ -143,3 +152,11 @@ Passing checks: The PostgreSQL counterpart `TestPostgresWorkflowParking` is implemented, but the required command is blocked because `KANDEV_TEST_POSTGRES_DSN` is unset. The work order therefore remains in progress until that database check runs. + +## Follow-up: Session-open recovery eligibility + +The [recovery eligibility repair](../session-open-recovery-eligibility/plan.md) +owns the historical-stop and settled-deferral regressions found after restart. +It replaces the parking suppression policy and removes the parking note, with +separate and combined recovery cases on desktop and phone. Existing results and PostgreSQL prerequisites here +remain unchanged. The follow-up work order is pending implementation. diff --git a/docs/plans/queued-session-ownership/task-03-expose-queue-status.md b/docs/plans/queued-session-ownership/task-03-expose-queue-status.md index a46aca516a..2ba388bfc2 100644 --- a/docs/plans/queued-session-ownership/task-03-expose-queue-status.md +++ b/docs/plans/queued-session-ownership/task-03-expose-queue-status.md @@ -22,6 +22,15 @@ system_design: # Task 03: Expose queue status across task surfaces +## Policy supersession, 2026-09-18 + +The [revised conversation recovery package](../session-open-recovery-eligibility/plan.md) +supersedes parked-session suppression and parking-note presentation in this +historical package. Opening an earlier conversation now follows normal recovery. +Keep queue identity, admission, callback, and reconciliation coverage. Replace +old no-resume and parked-note assertions in the revised package's work orders. +Historical results and outstanding PostgreSQL checks below are unchanged. + ## Summary Deliver the bounded queue summary and its desktop/phone presentation as one diff --git a/docs/plans/session-open-recovery-eligibility/plan.md b/docs/plans/session-open-recovery-eligibility/plan.md new file mode 100644 index 0000000000..9f77f31c9b --- /dev/null +++ b/docs/plans/session-open-recovery-eligibility/plan.md @@ -0,0 +1,211 @@ +--- +created: 2026-09-18 +status: in_progress +requirements: + - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-001 + - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-002 + - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-003 +system_design: + - ../../specs/tasks/system-design/queued-session-ownership.md +legacy_specs: [] +--- + +# Plan: Resume conversations on open without parking UI + +## Overview + +Opening a workflow-stopped conversation uses normal automatic recovery, including +older non-primary sessions. Remove the parked-session banner without replacing +it with another state the user must understand. + +This replaces the earlier narrow exception for a reused primary session. +The user's 2026-09-18 instruction explicitly changes the parking policy. +[The new decision](../../decisions/2026-09-18-session-open-resumes-conversation.md) +records that change. The task system owns conversation activation and workflow +recipient isolation. No material product question remains unresolved. + +Implement two sequential work orders: first correct recovery and ownership +handling; then remove parking presentation and verify the full desktop/phone flow. + +## Evidence and conformance + +The original investigation of task `62f15ae6-bb2d-40c5-863b-91366636114d` +confirmed `needs_resume=true` with `auto_resume_allowed=false` and +`ownership_unavailable` on build `f4c9131307`. A consumed workflow-switch stop +marker blocked recovery. An empty `deferred_launch: {}` was a second blocker. +Those branches originated in #3755; #3776 and #3779 corrected separate deadlocks. + +The user then supplied task `efe289d6-a6cc-4f7b-9c9d-0c3014643270` and a +screenshot of the parked-session note. The user rejected the note and requested +resume on open even while a session is parked. The screenshot is presentation +evidence; this plan does not claim a new live-state investigation of that task. + +The old inspection requirement 001.1 and parked-note requirement 003.3 are +explicitly superseded by 001.9 and 003.8. Criteria 001.7/8 retain restart and +settled-queue coverage. Queue identity, automatic admission, callback correlation, +and the deadlock fixes remain compatibility constraints. + +## Scope + +### In scope + +- Remove workflow parking and historical stop markers as recovery restrictions. +- Treat absent, null, and empty settled deferrals as no pending launch. +- Resume only the selected conversation, without changing workflow ownership. +- Remove parked-session presentation, unused projection helpers, and locale copy. +- Audit policy-only parking writers/readers and remove unused code without migrations. +- Cover free/full capacity, pending destination, restart, preference, and delayed events. +- Update public recovery documentation when implementation ships. + +### Out of scope + +- Resending interrupted turns or settled workflow prompts on open. +- Changing workflow primary selection, automatic capacity limits, or manual overrides. +- Removing genuine launch queue status or background-work/Office parking. +- New session states, scheduler queues, migrations, or live-data cleanup. +- Relaunching explicitly cancelled, archived, or completed sessions outside existing rules. + +## Technical approach + +Use [Conversation recovery and workflow stop history](../../specs/tasks/system-design/queued-session-ownership.md#conversation-recovery-and-workflow-stop-history). +Remove parking-policy checks from `autoResumeEligibility`. Keep authorization, +resumability, archive/terminal rules, and nonempty deferred ownership validation. +Keep the general stop-intent parser and callback fences intact. + +A queued destination must not get a duplicate resume. A selected sibling can +resume when capacity permits, while the original deferred recipient and prompt +stay unchanged. If capacity is full, do not replace accepted work with a sibling +resume record. Test the existing admission/conflict paths rather than creating +another queue. A queue or route change after status still requires guarded +validation. A new parking marker alone is no longer a denial reason. + +Audit `workflow_parking` readers and writers across models, repository, +orchestrator, tests, and web. Remove policy-only code when unused; document any +retained independent purpose. Stored legacy markers become inert. Do not broaden +this cleanup to execution-stamped stop intents or unrelated parking concepts. + +Remove `ParkedSessionNote` and `hasWorkflowParkingMarker` from +`launch-queue-status.tsx`, their caller in `task-chat-panel.tsx`, and their tests. +Remove `task:parkedSessionNote` from every catalog. Keep real queue status. + +## ASCII UI preview + +### UI-01: Desktop, earlier conversation selected + +```text +Before +[Astra] [Luna] [Plan] +[This session is parked for the workflow. Opening it does not resume it...] +Conversation +Composer + +After +[Astra] [Luna] [Plan] +Conversation (existing recovery/loading indication when needed) +Composer +``` + +When a genuine launch is queued, its existing task-level status remains visible. +No replacement parking row, badge, toolbar, or recovery instruction appears. +Session tabs keep their current location; conversation content owns scrolling. + +### UI-02: Phone, earlier conversation selected + +```text +Task header +[Selected session v] -> existing Sessions picker +Conversation +Composer +[Chat | Plan | Changes | More] +``` + +The task drawer remains the entry point. The session picker selects the +conversation and normal recovery follows. The dedicated mobile layout, safe-area +navigation, and single chat scroll owner remain. No hover interaction is required. + +UI-01 and UI-02 cover AC 001.9 and 003.8. Placement and absence of parking chrome +are requirements; text spacing is illustrative. Existing recovery errors, queue +waiting, loading, and explicit controls keep their established presentation. +The nearest mobile exemplar is `mobile-queued-session-ownership.spec.ts`. + +## Test matrix + +AC suffixes use `AC-TASKS-QUEUED-SESSION-OWNERSHIP-`. + +| Case | Expected outcome | Criteria | +| --- | --- | --- | +| Primary or non-primary stopped conversation; valid or legacy parking; no pending work | Normal automatic recovery; no ownership transfer | 001.7, 001.9 | +| Consumed/unconsumed stop tombstone; malformed parking only | Parking history does not block recovery; callback correlation remains | 001.9 | +| Empty settled deferral, alone and with parking history | Recovery allowed; no settled prompt replay | 001.8, 001.9 | +| Same session already owns queued launch | No duplicate resume or prompt | 001.2; 002.4 | +| Different destination queued; selected sibling; free capacity | Selected conversation recovers; destination record and primary stay unchanged | 001.2, 001.5, 001.9 | +| Different destination queued; full capacity | No manual override or conflicting queue replacement | 001.2, 001.5; 002.6 | +| Auto-start prevention enabled | Open remains stopped; existing explicit Resume works | 001.5, 001.9 | +| Archive, explicit cancellation, completion, authorization failure | Existing lifecycle restrictions remain | 001.9 | +| Allowed status followed by conflicting queue or route change | Guarded admission rejects stale execution | 001.2, 001.6; 002.6 | +| Restart after switch or queue settlement | Conversation context survives; opening requests normal recovery | 001.7, 001.8, 001.9 | +| Desktop/phone with stored parking marker | No parked note; ordinary controls and real queue status remain | 003.8 | + +## E2E tests + +Update the existing desktop/mobile queued-session ownership specs and helper. +Replace assertions that require zero predecessor activation or a parked note. +Assert the selected conversation resumes without clicking Resume or sending a +message when preference and capacity permit. Capture `session_open` intent and +provider readiness; workspace readiness alone is insufficient. + +Retain exact destination queue/prompt/primary assertions. Keep source message and +turn counts unchanged where open only restores provider context. Test free and +full capacity separately, including no manual override and no queue replacement. +Release only fixture capacity holders and assert one destination prompt delivery. + +Use a workflow switch to establish real stopped-session metadata. Add restart +via the isolated `backend.restart()` fixture after leaving the target page. +Cover historical marker only, empty settled deferral only, and both together. +Verify same conversation identity and no prompt replay after recovery. + +Use the existing phone task drawer and session picker at 393 px. Assert no +parking note, no document horizontal overflow, and usable existing controls. +Restore settings and runtime overrides in `finally`. No developer-instance restart +or direct production database mutation is authorized. + +## Work orders + +- [x] [Task 01: Restore ordinary recovery for workflow-stopped sessions](task-01-repair-session-open-eligibility.md) +- [ ] [Task 02: Remove parking presentation and verify user flows](task-02-remove-parking-presentation.md) + +Order: 01 then 02. No subagents are authorized. + +## Related delivery records + +[Queued ownership](../queued-session-ownership/plan.md) is historical delivery +context. Its parked-suppression and parked-note scenarios are superseded by this +package. Exact queue ownership tests remain relevant. Keep historical verification +results and outstanding PostgreSQL checks unchanged; record new results here. + +[Boot deadlock](../ceiling-boot-deadlock/plan.md) and +[replay/cancellation deadlock](../ceiling-replay-cancellation-deadlock/plan.md) +remain compatibility inputs. Do not change their lock order or claim fences. + +## Documentation impact + +Task 02 updates `docs/public/tasks-and-workflows.md` and +`docs/public/agents-and-profiles.md`: opening restores the selected conversation +under normal controls without changing workflow ownership. Remove instructions +that require a message solely because the workflow stopped the conversation. +No public behavior claim ships during this design-only turn. + +## Verification results + +Task 01 backend implementation is complete. Focused recovery, race, model/SQLite, +build, lint, and the full orchestrator package pass. The complete backend test +target still reports unrelated environment-sensitive failures in probe process +detection and home configuration discovery. Task 02 remains pending for the +frontend presentation removal and desktop/mobile E2E coverage. + +## Risks + +- Removing callback tombstones can let stale events affect a resumed execution. +- Opening a sibling can consume capacity, but must never steal its destination's queue. +- Hiding the note without changing eligibility leaves the reported defect intact. +- Tests that mistake workspace recovery for agent recovery give false confidence. diff --git a/docs/plans/session-open-recovery-eligibility/task-01-repair-session-open-eligibility.md b/docs/plans/session-open-recovery-eligibility/task-01-repair-session-open-eligibility.md new file mode 100644 index 0000000000..7c2cdb1975 --- /dev/null +++ b/docs/plans/session-open-recovery-eligibility/task-01-repair-session-open-eligibility.md @@ -0,0 +1,131 @@ +--- +id: "01-repair-session-open-eligibility" +title: "Restore ordinary recovery for workflow-stopped sessions" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-001 + - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-002 + - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-003 +acceptance_criteria: + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.2 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.5 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.6 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.7 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.8 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.9 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.4 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.6 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.8 +system_design: + - ../../specs/tasks/system-design/queued-session-ownership.md +--- + +# Task 01: Restore ordinary recovery for workflow-stopped sessions + +## Summary + +Remove workflow parking as an automatic recovery restriction. Preserve workflow +recipient isolation, automatic capacity admission, and execution-correlated callbacks. + +## In scope + +- Add `session_open_recovery_test.go` with `TestSessionOpenRecoveryEligibility`, + `TestSessionOpenRecoveryOwnership`, `TestSessionOpenRecoveryAfterRestart`, and + `TestSessionOpenRecoveryDelayedCallbacks`. +- Record RED for modern parking, historical stop intent, empty deferral, and their combination. +- Cover primary/non-primary sessions and the full backend matrix in the plan. +- Remove parking and stop-intent suppression from `autoResumeEligibility`. +- Accept empty settled deferrals; preserve nonempty queue validation. +- Exercise status, `passiveLaunchResponse`, `EnsureSession`, and real automatic admission. +- Verify full-capacity sibling recovery cannot replace another destination's accepted work. +- Audit parking-only writers/readers and remove unused helpers with their obsolete tests. +- Keep stop-intent tombstones, callback parsing, and independent lifecycle safeguards. +- Update old tests that assert parked-session suppression to assert the new contract. + +## Out of scope + +UI removal and rendered tests belong to Task 02. No live-data edits, migration, +new queue, manual ceiling override, workflow promotion, or prompt replay. + +## Acceptance + +1. Selected stopped conversations, including non-primary ones, recover under normal + rules regardless of workflow parking metadata. Empty settled records do not block them. +2. Actual queued destinations, guarded admission, capacity, archive, terminal, and + authorization rules remain intact. Opening a sibling never changes the queued prompt or primary. +3. Persisted restart tests and delayed-event tests pass without deleting stop tombstones. + Existing deadlock regressions remain green. + +## Verification + +Run from repository root. Add proposed tests before their commands. + +```bash +(cd apps/backend && go test ./internal/orchestrator -run '^TestSessionOpenRecovery' -count=1 -timeout=120s) +(cd apps/backend && go test -race ./internal/orchestrator -run 'TestSessionOpenRecovery|TestAutoResumeEligibility|TestGetTaskSessionStatus|TestPassiveLaunchResponse|TestEnsureSession|Test.*WorkflowParking|Test.*ProfileSwitch|Test.*WorkflowRoute|TestCeilingReplay' -count=1 -timeout=300s) +(cd apps/backend && go test ./internal/task/models ./internal/task/repository/sqlite -run 'Test.*WorkflowParking|Test.*DeferredLaunch' -count=1 -timeout=120s) +git diff --check +``` + +Use real repository persistence and startup reconciliation for restart coverage. +Test queue/route replacement between status and launch with deterministic barriers. +Audit existing test names when removing policy-only code so remaining behavior +still has coverage. If dialect-sensitive repository behavior changes, add its +PostgreSQL conformance command before completion. No database migration is planned. + +## Files likely touched + +- `apps/backend/internal/orchestrator/task_operations.go`. +- `apps/backend/internal/orchestrator/session_launch.go` and `session_ensure.go`. +- `apps/backend/internal/orchestrator/workflow_session_target.go`. +- `apps/backend/internal/orchestrator/workflow_profile_session_lifecycle.go`. +- New `apps/backend/internal/orchestrator/session_open_recovery_test.go`. +- Existing resume, launch, ensure, workflow-target, and profile-switch tests. +- Parking-only model/repository helpers and tests, only if the consumer audit proves them unused. + +## Dependencies + +None. This replaces the prior work order's current-primary-only exception. + +## Risks + +Stop-intent metadata is not disposable parking UI state. Preserve event identity +checks. A queue conflict must not silently redirect a selected conversation. + +## Parallelism + +`sequential` + +## Inputs + +- [Plan and matrix](plan.md). +- [Design](../../specs/tasks/system-design/queued-session-ownership.md#conversation-recovery-and-workflow-stop-history). +- [Decision](../../decisions/2026-09-18-session-open-resumes-conversation.md). +- Existing automatic admission, workflow reuse, and stop-event regression fixtures. + +## Results + +Implemented the backend recovery contract. + +- `autoResumeEligibility` no longer treats workflow parking or historical stop-intent metadata as passive recovery ownership. +- Empty settled `deferred_launch` objects are accepted as having no pending launch. +- Nonempty deferred launches still require valid destination identity and retain the queued-destination guard. +- The guarded `session_open` admission boundary rechecks current queue ownership under the existing lifecycle and ceiling-entry lock order. A capacity conflict with another accepted destination returns a successful waiting disposition and preserves that record. +- Added real `LaunchSession` coverage for status-driven recovery, free/full capacity, unchanged queued recipient and prompt, primary preservation, no manual override, no runtime launch on refusal, and queue replacement after the initial eligibility read. +- Added restart, successor-execution delayed-callback, stale-ownership, status, passive-launch, `EnsureSession`, primary/non-primary, parking, stop-intent, and settled-record regressions in the split session-open recovery test files. The delayed-callback case keeps an active successor turn and verifies route, primary, execution, and accepted queue state across service reconstruction. +- Updated existing resume and passive-launch tests to preserve the new recovery contract while retaining stop tombstones and parking lifecycle behavior. + +Verification: + +- Passed `go test ./internal/orchestrator -run '^TestSessionOpenRecovery' -count=1 -timeout=120s`. +- Passed the prescribed orchestrator race matrix. +- Passed the workflow parking and deferred-launch model/SQLite checks. +- Passed `make -C apps/backend build` and `make -C apps/backend lint`. +- Passed the full `internal/orchestrator` package. +- `git diff --check` passed. +- The complete backend test target reported unrelated environment-sensitive failures in `internal/agentctl/server/process/probe`, `internal/common/config`, and `internal/launcher`; all other reported packages, including the changed package, passed. + +Task 02 owns the frontend parking presentation removal and desktop/mobile E2E coverage. diff --git a/docs/plans/session-open-recovery-eligibility/task-02-remove-parking-presentation.md b/docs/plans/session-open-recovery-eligibility/task-02-remove-parking-presentation.md new file mode 100644 index 0000000000..a9cab7c7ae --- /dev/null +++ b/docs/plans/session-open-recovery-eligibility/task-02-remove-parking-presentation.md @@ -0,0 +1,125 @@ +--- +id: "02-remove-parking-presentation" +title: "Remove parking presentation and verify user flows" +status: pending +wave: 2 +depends_on: ["01-repair-session-open-eligibility"] +plan: "plan.md" +requirements: + - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-001 + - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-002 + - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-003 +acceptance_criteria: + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.2 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.5 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.6 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.7 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.8 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.9 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.4 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.6 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.8 +system_design: + - ../../specs/tasks/system-design/queued-session-ownership.md +--- + +# Task 02: Remove parking presentation and verify user flows + +## Summary + +Remove the parked-session note from shared chat surfaces. Verify selected +conversation recovery on desktop and phone without adding another parking affordance. + +## In scope + +- Remove `ParkedSessionNote`, `hasWorkflowParkingMarker`, and their chat-panel use. +- Remove `parkedSessionNote` from en, pseudo, pt-pt, zh-cn, zh-hk, and zh-tw catalogs. +- Replace parked-note tests with absence and ordinary-conversation-control assertions. +- Update desktop/mobile queued-session E2E scenarios according to the plan matrix. +- Verify restart, capacity, prevention preference, conversation identity, and no prompt replay. +- Update both public recovery explanations and affected companion-plan references. + +## Out of scope + +No replacement parking toolbar, badge, tooltip, or composer restriction. Genuine +queue status remains. No new layout, navigation hierarchy, or automatic prompt. + +## Acceptance + +1. No parking presentation appears on desktop or phone, including with persisted legacy markers. +2. Opening the selected stopped session resumes it when normal controls permit, + without clicking Resume. Queue ownership and workflow primary remain unchanged. +3. Both E2E projects and targeted UI/i18n checks pass. Public docs describe the new behavior. + +## ASCII UI preview + +Use [UI-01 and UI-02](plan.md#ascii-ui-preview). + +```text +Desktop: [Session tabs] -> Conversation -> Composer +Phone: [Session picker] -> Conversation -> Composer -> Bottom navigation +Both: No parked-session row; genuine task queue status remains when needed. +``` + +Keep the existing desktop tabs and phone task drawer/session picker. +The chat remains the single conversation scroll owner. No new touch targets or +hover-only behavior are introduced. Cover AC 001.9 and 003.8. + +## Verification + +Run from repository root; install dependencies once for a fresh worktree. + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm exec vitest run components/task/launch-queue-status.test.tsx hooks/domains/session/use-session-resumption.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm e2e:run --project chromium tests/workflow/queued-session-ownership.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/workflow/mobile-queued-session-ownership.spec.ts) +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.test.py +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +Run E2E projects sequentially with the managed runner. Use only disposable fixture +backends and scoped settings. Record behavioral RED before the UI removal and +GREEN after it. Backend Task 01 owns recovery RED; these tests prove integration. +Assert provider readiness independently of workspace-only readiness. + +## Files likely touched + +- `apps/web/components/task/launch-queue-status.tsx` and its test. +- `apps/web/components/task/task-chat-panel.tsx`. +- `apps/web/hooks/domains/session/use-session-resumption.test.ts`. +- `apps/web/src/locales/{en,pseudo,pt-pt,zh-cn,zh-hk,zh-tw}/task.json`. +- `apps/web/e2e/tests/workflow/queued-session-ownership.spec.ts`. +- `apps/web/e2e/tests/workflow/mobile-queued-session-ownership.spec.ts`. +- `apps/web/e2e/tests/workflow/queued-session-ownership-helpers.ts`. +- `docs/public/tasks-and-workflows.md` and `docs/public/agents-and-profiles.md`. +- This package's status/results and companion references. + +## Dependencies + +Task 01. It supplies the backend behavior required by the rendered tests. + +## Risks + +A hidden note is insufficient if recovery still fails. Do not remove the genuine +queue region or confuse workspace restoration with a resumed provider session. +Restore fixture capacity and user settings even when assertions fail. + +## Parallelism + +`sequential` + +## Inputs + +- [Plan, previews, and E2E matrix](plan.md). +- [Design](../../specs/tasks/system-design/queued-session-ownership.md#conversation-recovery-and-workflow-stop-history). +- Existing phone queued-session E2E fixture and session page object. +- Mobile parity and E2E skills; public docs maintenance guidance. + +## Results + +Pending. No frontend, E2E, or implementation check ran during planning. diff --git a/docs/specs/tasks/requirements/queued-session-ownership.md b/docs/specs/tasks/requirements/queued-session-ownership.md index 0089935d35..ee3590bc10 100644 --- a/docs/specs/tasks/requirements/queued-session-ownership.md +++ b/docs/specs/tasks/requirements/queued-session-ownership.md @@ -11,8 +11,8 @@ owners: ## Overview A workflow can select its next session before instance capacity permits launch. -Users must be able to inspect that task without waking a parked predecessor or -losing the visible queued state. The task system owns the selected recipient, +Users can open earlier conversations with normal recovery behavior without +changing the selected workflow recipient or losing the visible queued state. The task system owns the selected recipient, durable launch intent, and its presentation across task surfaces. This capability extends [workflow session lifecycle](workflow-profile-session-lifecycle.md). @@ -35,16 +35,17 @@ destination entry, whereas a ceiling can delay a session already selected by ent ### REQ-TASKS-QUEUED-SESSION-OWNERSHIP-001: Inspection preserves execution ownership -**Intent:** Reading a task must not wake its retired workflow participant. +**Intent:** Opening a conversation resumes it without transferring workflow ownership. #### Acceptance criteria -- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.1:** Passive inspection shall not - start, resume, prompt, or enqueue work for a parked predecessor. This applies - with free or full capacity and with either auto-start-on-open preference value. +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.1:** Superseded by criterion 001.9 + under [the session-open decision](../../../decisions/2026-09-18-session-open-resumes-conversation.md). + The original prohibition on resuming workflow-stopped conversations no longer applies. - **AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.2:** When a destination is queued, passive inspection of any conversation shall preserve its recipient, prompt, - queue time, primary ownership, and workflow step. It shall not create another launch. + queue time, primary ownership, and workflow step. It shall not duplicate or + replace that accepted launch. A different conversation can recover under 001.9. - **AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.3:** Explicit execution for a parked predecessor shall target that conversation only. It shall not replace the queued destination, consume its prompt, or acquire its workflow ownership. @@ -54,10 +55,26 @@ destination entry, whereas a ceiling can delay a session already selected by ent - **AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.5:** Passive recovery that remains eligible under existing preferences shall respect the automatic session ceiling. It shall never produce a manual-override audit message. -- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.6:** If current ownership cannot be +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.6:** If pending launch ownership cannot be established, passive inspection shall remain available without starting work or attempting a fresh-session fallback. Explicit recovery shall remain separately available. +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.7:** After a workflow legitimately + reuses a conversation, historical workflow stops shall not prevent its otherwise + eligible recovery after restart. Desktop and phone task opening shall honor + the existing auto-start preference and automatic capacity limit. Pending launch ownership shall still prevent duplicate execution. + Workflow parking shall not independently block recovery. +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.8:** After a deferred launch settles + and no pending launch remains, queue history shall not block otherwise eligible + open-time recovery. Recovery shall preserve the conversation and shall not + replay the settled workflow prompt. + +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.9:** Opening or selecting a + workflow-stopped conversation shall use normal automatic recovery, including + non-primary conversations. Recovery shall honor auto-start prevention, capacity, + authorization, archive, and terminal-session rules. It shall preserve conversation + context without sending a new prompt or transferring workflow ownership. + ### REQ-TASKS-QUEUED-SESSION-OWNERSHIP-002: Deferred work survives sibling lifecycle events **Intent:** A queued launch must remain owned and retryable until its own outcome is known. @@ -99,9 +116,8 @@ destination entry, whereas a ceiling can delay a session already selected by ent queued destination, waiting reason, queue time, capacity observation, and automatic retry explanation outside the historical transcript. This status shall remain visible when the user selects a parked predecessor. -- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.3:** A parked conversation shall be - identified as parked for the workflow. A queued destination shall not offer - a misleading recovery prompt or imply that the user must restart it. +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.3:** Superseded by criterion 003.8. + The original requirement to identify a conversation as parked no longer applies. - **AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.4:** Queue status shall clear after confirmed dispatch or final disposition. Older snapshots shall not restore a cleared queue or erase newer queued work. Pending questions and real errors @@ -128,6 +144,11 @@ destination entry, whereas a ceiling can delay a session already selected by ent After a limit change, the banner shall show refreshed state without a page reload and remain until dispatch or final disposition is confirmed. +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.8:** Desktop and phone conversation + surfaces shall not show a parked-session banner, toolbar, badge, or recovery + explanation. Normal conversation controls shall remain available. Genuine queued + destinations shall retain queue status without a duplicate Start or Resume action. + ## Compatibility and exclusions Actual manual starts and resumes retain the existing ceiling override. This diff --git a/docs/specs/tasks/system-design/queued-session-ownership.md b/docs/specs/tasks/system-design/queued-session-ownership.md index 0896c3184e..0ee04cb245 100644 --- a/docs/specs/tasks/system-design/queued-session-ownership.md +++ b/docs/specs/tasks/system-design/queued-session-ownership.md @@ -21,7 +21,7 @@ and task-owned deferred record. It does not replace either queue or change WIP a | Requirement | Sections | | --- | --- | -| REQ-TASKS-QUEUED-SESSION-OWNERSHIP-001 | Inspection intent; Durable workflow parking | +| REQ-TASKS-QUEUED-SESSION-OWNERSHIP-001 | Inspection intent; Conversation recovery and workflow stop history | | REQ-TASKS-QUEUED-SESSION-OWNERSHIP-002 | Deferred entry ownership; Task reconciliation | | REQ-TASKS-QUEUED-SESSION-OWNERSHIP-003 | Queue projection; Desktop and mobile surfaces; Failure and observability | | REQ-TASKS-WORKFLOW-CANCELLED-TURN-COMPLETION-001 | Replay and reconciliation locking | @@ -52,8 +52,9 @@ was omitted or false. Internal workflow and peer-message paths retain their existing explicit origins; do not mark all automatic work as passive inspection. Add `auto_resume_allowed` and `auto_resume_blocked_reason` to session status. -Reasons are closed values: `workflow_parked`, `launch_queued`, or -`ownership_unavailable`; absence means no new ownership restriction. Keep +Keep `launch_queued` and `ownership_unavailable` for pending launch restrictions. +Stop emitting `workflow_parked`; tolerate older payloads during compatibility handling. +Absence means no pending launch restriction. Keep `is_resumable` for explicit recovery. Populate this through one orchestrator eligibility helper shared by status, launch, and open-time ensure paths. @@ -71,40 +72,64 @@ successful no-execution disposition, proposed `activation_disposition: not launch success or an error that triggers workspace/fresh fallback. Transport errors stay errors. Preserve request-generation guards on late responses. -For an ordinary recoverable session without parking or pending work, retain the +For a recoverable conversation, including a workflow-stopped predecessor, retain the preference behavior and use automatic ceiling admission. If it is deferred, retain inspection source in its replay payload and revalidate eligibility on retry. If a task already has a different accepted launch, inspection cannot replace it -or add a conflicting resume record. It returns the existing queued disposition. +or add a conflicting resume record. An admitted sibling recovery can proceed. +A capacity refusal preserves the accepted record and returns the existing queued +disposition without pretending the selected sibling started. -## Durable workflow parking +## Conversation recovery and workflow stop history -Add a typed `workflow_parking` session metadata value with `stamp`, -`parked_at`, and source entry identity where available. Record it even when no -runtime execution exists. This is current policy, not evidence of background work. -Do not reuse `parked_on_background_work` or clear the stop-intent tombstone. +Follow [the session-open decision](../../../decisions/2026-09-18-session-open-resumes-conversation.md) +and AC 001.9/003.8. Opening an earlier conversation is sufficient to request +normal automatic recovery. No primary-role or committed-route exception is needed +solely because the conversation was stopped by a workflow switch. -Write parking with the parked session transition under the existing lifecycle -guard and a conditional repository operation. A failed transition must not leave -an unowned parking marker. Clear only the matching stamp when explicit execution -is accepted or a workflow entry commits that session as recipient. No mark is -cleared on a status read, focus, denied admission, or transient launch failure. -Coordinate clear with the launch claim so failed attempts leave the conversation -protected from subsequent passive recovery. A later park supersedes an older clear. +Remove `workflow_parking` and workflow-switch stop-intent checks from +`autoResumeEligibility`. Neither valid, consumed, unconsumed, nor malformed +legacy parking data grants or denies recovery. Task/session authorization, +archive, terminal state, error recovery, and actual pending launch ownership +remain separate checks. Do not infer a new prompt from session opening. -Existing parked tasks may lack this marker. During status/launch eligibility, -recognize an execution-stamped workflow stop intent (including consumed state) -only with non-primary source identity and no newer authorized activation/route. -Inspect durable session execution timestamps and the committed workflow route; -do not equate any WAITING_FOR_INPUT session with parking. If legacy evidence is -ambiguous, deny passive execution with `ownership_unavailable`. Reading remains -possible, and explicit recovery or an authoritative workflow selection resolves -the ambiguity. Never fabricate queue work for an uncertain predecessor. +Keep execution-stamped stop-intent parsing and consumed tombstones in the +callback path. They reject delayed events for the old execution. Removing +parking as an activation restriction does not remove event correlation. +Inspect every `workflow_parking` consumer and producer. Remove policy-only +helpers and writes when unused; retain any code with a demonstrated independent +lifecycle purpose. Existing metadata remains inert without a schema migration +or destructive backfill. Do not remove background-work or Office parking. -Keep metadata parsing in task models and conditional writes in the task -repository, with SQLite/PostgreSQL conformance coverage. No new state enum or -database column is required. Tests must include parking without a runtime, -consumed tombstones, later reuse, explicit follow-up, restart, and stale clears. +The shared eligibility predicate still handles deferred launch metadata. Absent, +null, or empty objects represent no pending launch. `stripCeilingRecordKeys` +produces empty objects after settlement. Preserve nonempty-record validation; +do not weaken the replay parser to fix status inspection. + +A queued destination remains owned by its accepted launch. Opening it cannot +create another resume. A different selected session can recover with available +capacity, without primary promotion, route changes, or destination prompt delivery. +If capacity is full, preserve the existing deferred record. Do not overwrite it +with the selected sibling's automatic resume. Existing retry and queue mechanisms +remain authoritative; no second task queue or hidden polling loop is introduced. + +Status and launch must agree about these rules. Recheck actual queue ownership +at guarded admission after an allowed status response. New parking metadata alone +cannot cancel that permission; a conflicting accepted launch still can. +Keep the lock order and claim fences described below. No runtime call occurs +under task admission. + +The browser retains `activation_source=session_open`, preference handling, and +ordinary recovery states. Remove `ParkedSessionNote`, its marker projection, and +its use in `task-chat-panel.tsx`. Remove the unused `parkedSessionNote` locale key +from all catalogs and update affected tests. Do not replace it with another +banner, chip, tooltip, disabled composer, or hidden recovery requirement. +The genuine `TaskLaunchQueueStatus` surface remains task-scoped. + +Desktop keeps its session tabs above chat. Phone keeps its existing session +picker and one conversation scroll area. Both show ordinary recovery after open. +Test provider readiness independently of workspace-only readiness. Recovery must +preserve context without resending an interrupted or settled workflow prompt. ## Deferred entry ownership @@ -148,7 +173,7 @@ CREATED row with no accepted launch is not sufficient. Read errors fail closed. Guard the write against the observed queue/route identity so a concurrent enqueue cannot lose to an older REVIEW writer. Preserve explicit terminal task actions. -When a user explicitly runs the parked sibling, retain queue status alongside +When a sibling resumes on open or explicitly runs, retain queue status alongside IN_PROGRESS; settling that sibling restores SCHEDULING. Do not promote it to primary or run destination entry actions. Keep existing completion-follow-up, Office, cancellation, and runtime-publication ordering protections. @@ -276,14 +301,15 @@ existing background-work indicators remain intact. No ordinal position is shown. Share a queue view model and task-scoped status component. Desktop `TaskSwitcher` rows show Queued text with an existing clock/status icon. Task details place the status above conversation content, independently of selected session and transcript -scroll. Show a separate parked note for the selected predecessor. The destination's +scroll. Do not show a parking-specific note for the selected conversation. The destination's CREATED start/recovery affordance yields to queued status while accepted work exists. Use the existing `SessionTaskSwitcherSheet` phone drawer and `session-mobile-layout.tsx` dedicated composition. Place a compact task queue region above `MobileSessionsPicker`, outside the chat scroll. The navigator row shows the same Queued label. Long names wrap in details and truncate in rows. -The user can inspect Astra while Luna remains named in the task queue region. +The user can open Astra while Luna remains named in the task queue region. +Astra follows normal recovery without taking over Luna's workflow ownership. This is persistent status, so do not add a second drawer or global dashboard. Keep the existing fixed header/navigation, one chat scroll owner, dynamic viewport, @@ -333,7 +359,7 @@ inspection. Localize labels and link text in all five catalogs. ## Failure and observability -Emit structured, bounded reason codes for suppressed inspection, queued replay, +Emit structured, bounded reason codes for suppressed duplicate launch, queued replay, stale-entry disposition, and protected state reconciliation. Include task, session, entry, and execution IDs in logs only, with no prompt content. Do not create lifecycle-only turns merely to announce a suppressed inspection. @@ -346,6 +372,7 @@ or claim its cause without a failing regression. ## Related records -- [Passive inspection decision](../../../decisions/2026-09-16-passive-session-inspection.md) +- [Conversation recovery decision](../../../decisions/2026-09-18-session-open-resumes-conversation.md) +- [Historical passive inspection decision](../../../decisions/2026-09-16-passive-session-inspection.md) - [Runtime state publication](runtime-state-publication-order.md) - [Implementation package](../../../plans/queued-session-ownership/plan.md) diff --git a/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md b/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md index 1f03bc7919..aca4623631 100644 --- a/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md +++ b/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md @@ -488,9 +488,12 @@ logs include session ID, execution ID, and intent stamp. ## Implementation plans - [Queued session ownership](../../../plans/queued-session-ownership/plan.md) - extends parking and admission through the paired + records the original parking and admission implementation through the paired [inspection and queue design](queued-session-ownership.md). Stop-intent tombstones remain callback evidence, not current activation policy. + The [session-open decision](../../../decisions/2026-09-18-session-open-resumes-conversation.md) + supersedes parking-based recovery suppression. Opening an earlier conversation + follows normal recovery without changing its workflow recipient role. - [Explicit session targeting](../../../plans/workflow-session-targeting/plan.md) - [Same-profile fresh-session repair](../../../plans/workflow-same-profile-new-session/plan.md)