diff --git a/apps/backend/internal/backendapp/gateway.go b/apps/backend/internal/backendapp/gateway.go index 28e64c797a..81efff6413 100644 --- a/apps/backend/internal/backendapp/gateway.go +++ b/apps/backend/internal/backendapp/gateway.go @@ -335,6 +335,22 @@ func provideGateway( return loadTaskGitObservations(ctx, taskRepo, taskID) }, LoadPullRequests: loadPullRequests, + LoadLaunchQueue: func(ctx context.Context, taskID string) (*statussummary.LaunchQueueSummary, error) { + task, err := taskRepo.GetTask(ctx, taskID) + if err != nil { + return nil, err + } + if task == nil { + return nil, fmt.Errorf("task %q not found", taskID) + } + observation, observationErr := orchestratorSvc.CurrentSessionCeilingObservation(ctx) + return statussummary.LaunchQueueSummaryFromTaskWithCapacity(task, &statussummary.LaunchQueueCapacityObservation{ + InUse: observation.InUse, + Limit: observation.Limit, + ObservedAt: observation.ObservedAt, + Known: observationErr == nil && observation.Known, + }), nil + }, ResolveWorkspace: func(ctx context.Context, taskID string) (string, error) { task, err := taskRepo.GetTask(ctx, taskID) if err != nil { diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index c045dc78bc..6cbc746775 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -41,6 +41,7 @@ import ( taskmodels "github.com/kandev/kandev/internal/task/models" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" + "github.com/kandev/kandev/internal/task/statussummary" userservice "github.com/kandev/kandev/internal/user/service" utilitymodels "github.com/kandev/kandev/internal/utility/models" utilityservice "github.com/kandev/kandev/internal/utility/service" @@ -199,6 +200,26 @@ func provideOrchestrator( // list/snapshot payloads (initial-load backstop for the sidebar badge; the // status-summary projector keeps the field live between loads). taskSvc.SetQueuedPromptCounter(orchestratorSvc.GetMessageQueue()) + // Rebuild task summaries with one current ceiling observation for the whole + // batch. A failed population read retains queue ownership but leaves the + // displayed count unavailable. + taskSvc.SetTaskStatusSummaryLaunchQueueReader(func(ctx context.Context, tasks []*taskmodels.Task) map[string]*statussummary.LaunchQueueSummary { + observation, observationErr := orchestratorSvc.CurrentSessionCeilingObservation(ctx) + capacity := &statussummary.LaunchQueueCapacityObservation{ + InUse: observation.InUse, + Limit: observation.Limit, + ObservedAt: observation.ObservedAt, + Known: observationErr == nil && observation.Known, + } + queues := make(map[string]*statussummary.LaunchQueueSummary, len(tasks)) + for _, task := range tasks { + if task == nil || task.ID == "" { + continue + } + queues[task.ID] = statussummary.LaunchQueueSummaryFromTaskWithCapacity(task, capacity) + } + return queues + }) // Per-user scoping for the session-keyed WS actions. The orchestrator // resolves sessions through its own repo handle, so it does not inherit the diff --git a/apps/backend/internal/orchestrator/ceiling_claim.go b/apps/backend/internal/orchestrator/ceiling_claim.go new file mode 100644 index 0000000000..170492ac30 --- /dev/null +++ b/apps/backend/internal/orchestrator/ceiling_claim.go @@ -0,0 +1,196 @@ +package orchestrator + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/task/models" +) + +// ErrCeilingLaunchClaimed reports that another dispatcher currently owns the +// exact durable ceiling launch. The caller must leave its own work queued and +// let the owner settle the record. +var ErrCeilingLaunchClaimed = errors.New("ceiling deferred launch is already claimed") + +const ( + ceilingClaimOwnerSendNow = "send_now" + ceilingClaimOwnerReplay = "ceiling_replay" + // The lease is long enough for a normal launch, but finite so a process + // exit cannot make a durable queue entry undispatchable forever. + ceilingClaimLeaseDuration = 5 * time.Minute +) + +// ceilingDeferredLaunchClaim is the ownership token for one exact ceiling +// record. It is persisted in the shared deferred_launch row while the runtime +// dispatch is in flight, which serializes replay with an explicit Send Now. +type ceilingDeferredLaunchClaim struct { + svc *Service + taskID string + deferral models.CeilingDeferral + id string + owner string + held bool +} + +// claimCeilingDeferredLaunch claims only a matching ceiling record. The bool +// reports that a matching record exists; a nil claim with true means another +// dispatcher owns it. A non-matching record is not an error because a task may +// receive a successor launch while an older queue snapshot is still visible. +func (s *Service) claimCeilingDeferredLaunch( + ctx context.Context, + taskID, expectedSessionID, owner string, +) (*ceilingDeferredLaunchClaim, bool, error) { + if s == nil || s.repo == nil || taskID == "" || owner == "" { + return nil, false, nil + } + ctx, release := s.lockCeilingEntryAdmission(ctx, taskID) + defer release() + for attempt := 0; attempt < deferredLaunchCASRetryBudget; attempt++ { + record, prior, err := s.repo.GetTaskDeferredLaunch(ctx, taskID) + if err != nil { + return nil, false, fmt.Errorf("read ceiling launch claim: %w", err) + } + deferral, err := models.ReadCeilingDeferral(record) + if err != nil { + return nil, false, nil + } + targetsSession, targetErr := s.ceilingDeferralTargetsSession( + ctx, taskID, deferral, expectedSessionID, + ) + if targetErr != nil { + return nil, false, fmt.Errorf("resolve ceiling launch recipient: %w", targetErr) + } + if !targetsSession { + return nil, false, nil + } + + if existingClaim, claimed := models.ReadCeilingLaunchClaimDetails(record); claimed && + !existingClaim.Expired(time.Now().UTC()) { + // The owner label describes the dispatcher class, not a re-entrant + // invocation. Two replay ticks (or two Send Now requests) can use the + // same label concurrently, so adopting a same-owner claim would let + // both callers dispatch the exact record. + return nil, true, nil + } + + updated := cloneCeilingRecord(record) + claimID := uuid.NewString() + updated[models.CeilingLaunchClaimKey] = map[string]interface{}{ + "id": claimID, + "owner": owner, + models.CeilingLaunchClaimExpiresAtKey: time.Now().UTC().Add(ceilingClaimLeaseDuration).Format(time.RFC3339Nano), + } + stored, lostCompare, err := s.repo.SetTaskDeferredLaunchIfUnchanged(ctx, taskID, prior, updated) + if err != nil { + return nil, false, fmt.Errorf("claim ceiling launch: %w", err) + } + if stored { + return &ceilingDeferredLaunchClaim{ + svc: s, taskID: taskID, deferral: deferral, + id: claimID, owner: owner, held: true, + }, true, nil + } + if !lostCompare { + return nil, false, fmt.Errorf("claim ceiling launch: repository reported no write") + } + } + return nil, false, fmt.Errorf("claim ceiling launch: compare-and-set retries exhausted") +} + +func cloneCeilingRecord(record map[string]interface{}) map[string]interface{} { + if record == nil { + return map[string]interface{}{} + } + clone := make(map[string]interface{}, len(record)+1) + for key, value := range record { + clone[key] = value + } + return clone +} + +// releaseIfHeld removes only this claim marker. The deferred launch remains +// intact so a pre-dispatch failure can be retried by the sweeper. +func (c *ceilingDeferredLaunchClaim) releaseIfHeld(ctx context.Context) { + if c == nil || !c.held || c.svc == nil { + return + } + if err := c.svc.mutateCeilingClaim(context.WithoutCancel(ctx), c, false); err != nil { + c.svc.logger.Zap().Warn("could not release ceiling launch claim", + zap.String("task_id", c.taskID), zap.Error(err)) + return + } + c.held = false +} + +// settle consumes the exact deferred record after dispatch was accepted. WIP +// keys that share the row remain untouched. +func (c *ceilingDeferredLaunchClaim) settle(ctx context.Context) { + if c == nil || !c.held || c.svc == nil { + return + } + if err := c.svc.mutateCeilingClaim(context.WithoutCancel(ctx), c, true); err != nil { + c.svc.logger.Zap().Warn("could not settle ceiling launch claim", + zap.String("task_id", c.taskID), zap.Error(err)) + // The dispatch was accepted. Keep the marker until its lease expires so + // a retry cannot immediately replay the same prompt. A later dispatcher + // can reclaim the marker and settle the exact record if the CAS failed. + c.held = false + return + } + c.held = false +} + +// mutateCeilingClaim performs the compare-and-set settlement for one claim. +// A changed deferral or claim is never modified by this older owner. +func (s *Service) mutateCeilingClaim(ctx context.Context, claim *ceilingDeferredLaunchClaim, settle bool) error { + for attempt := 0; attempt < deferredLaunchCASRetryBudget; attempt++ { + record, prior, err := s.repo.GetTaskDeferredLaunch(ctx, claim.taskID) + if err != nil { + return err + } + current, err := models.ReadCeilingDeferral(record) + if err != nil { + return nil + } + equivalent, err := sameCeilingDeferralIdentity(current, claim.deferral) + if err != nil { + return err + } + if !equivalent { + // Capacity observations are mutable bookkeeping. The claim id below + // still binds the mutation to this exact in-flight owner, while the + // stable deferral identity prevents a successor from being touched. + return nil + } + claimID, _, ok := models.ReadCeilingLaunchClaim(record) + if !ok || claimID != claim.id { + return nil + } + + updated := cloneCeilingRecord(record) + if settle { + updated = stripCeilingRecordKeys(updated) + } else { + delete(updated, models.CeilingLaunchClaimKey) + } + stored, lostCompare, err := s.repo.SetTaskDeferredLaunchIfUnchanged(ctx, claim.taskID, prior, updated) + if err != nil { + return err + } + if stored { + if settle { + s.publishTaskUpdatedByID(ctx, claim.taskID) + } + return nil + } + if !lostCompare { + return fmt.Errorf("repository reported no claim settlement") + } + } + return fmt.Errorf("claim settlement compare-and-set retries exhausted") +} diff --git a/apps/backend/internal/orchestrator/ceiling_defer.go b/apps/backend/internal/orchestrator/ceiling_defer.go index 51b2de01e6..751faf18e3 100644 --- a/apps/backend/internal/orchestrator/ceiling_defer.go +++ b/apps/backend/internal/orchestrator/ceiling_defer.go @@ -9,6 +9,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" ) // deferredLaunchCASRetryBudget bounds the read-compare-write retry AC-40a @@ -50,6 +51,12 @@ func (s *Service) deferCeilingRefusal( ctx context.Context, taskID, sessionID string, kind models.CeilingLaunchKind, payload map[string]interface{}, reasonCode string, population int, populationKnown bool, ceiling int, ) error { + ctx, release := s.lockCeilingEntryAdmission(ctx, taskID) + defer release() + // Capture the route/entry identity that is already committed at the + // admission boundary. The payload remains the first launch description; + // this only adds the durable binding needed to reject stale replay. + payload = s.enrichCeilingLaunchPayload(ctx, taskID, sessionID, payload) for attempt := 0; attempt < deferredLaunchCASRetryBudget; attempt++ { existingRaw, prior, err := s.repo.GetTaskDeferredLaunch(ctx, taskID) if err != nil { @@ -69,7 +76,7 @@ func (s *Service) deferCeilingRefusal( if existingCeiling, readErr := models.ReadCeilingDeferral(existingRaw); readErr == nil { // A ceiling_deferred record already exists for this task (AC-12d). - equivalent, cmpErr := models.CeilingDeferralsEquivalent(existingCeiling, deferral) + equivalent, cmpErr := ceilingDeferralsEquivalentForAdmission(existingCeiling, deferral) if cmpErr != nil { return fmt.Errorf("comparing deferred launch payloads for task %s: %w", taskID, cmpErr) } @@ -87,6 +94,7 @@ func (s *Service) deferCeilingRefusal( // AC-12a/AC-49e: the same launch, refused for the same reason, // already recorded. Leave it exactly as stored, including its // queued_at (AC-32) and its card-surface bookkeeping. + s.reconcileQueuedTaskState(ctx, taskID) return nil } // AC-49f: the same pending launch, but the reason changed (for @@ -100,6 +108,7 @@ func (s *Service) deferCeilingRefusal( updated.PopulationKnown = populationKnown updated.Ceiling = ceiling if s.writeCeilingDeferralUpdate(ctx, taskID, existingRaw, prior, updated) { + s.reconcileQueuedTaskState(ctx, taskID) s.attemptCeilingSurfaceWrite(ctx, taskID) s.publishTaskUpdatedByID(ctx, taskID) } @@ -117,6 +126,7 @@ func (s *Service) deferCeilingRefusal( return fmt.Errorf("writing deferred launch for task %s: %w", taskID, err) } if stored { + s.reconcileQueuedTaskState(ctx, taskID) s.attemptCeilingSurfaceWrite(ctx, taskID) s.publishTaskUpdatedByID(ctx, taskID) return nil @@ -130,6 +140,50 @@ func (s *Service) deferCeilingRefusal( ceilingReasonDeferWriteFailed, taskID, deferredLaunchCASRetryBudget) } +// reconcileQueuedTaskState repairs legacy task rows as soon as the durable +// ceiling entry is known to exist. It only changes an authoritative queued +// destination with no working session, so a message or turn that legitimately +// made the task active remains the owner of IN_PROGRESS. +func (s *Service) reconcileQueuedTaskState(ctx context.Context, taskID string) { + if s == nil || s.repo == nil || s.taskRepo == nil || taskID == "" { + return + } + ctx, release := s.lockCeilingEntryAdmission(ctx, taskID) + defer release() + task, err := s.repo.GetTask(ctx, taskID) + if err != nil || task == nil { + if err != nil { + s.logger.Zap().Warn("could not load task before repairing state after ceiling deferral", + zap.String("task_id", taskID), zap.Error(err)) + } + return + } + deferral, queued, err := s.readValidCeilingDeferredLaunch(ctx, task) + if err != nil || !queued { + if err != nil { + s.logger.Zap().Warn("could not validate deferred launch before repairing task state", + zap.String("task_id", taskID), zap.Error(err)) + } + return + } + if blockingSessionID, readable := s.otherWorkingSessionID(ctx, taskID, ""); !readable || blockingSessionID != "" { + return + } + allowedStates := []v1.TaskState{v1.TaskStateReview, v1.TaskStateInProgress} + updated, err := s.taskRepo.UpdateTaskStateIfCurrentIn( + ctx, taskID, v1.TaskStateScheduling, allowedStates, + ) + if err != nil { + s.logger.Zap().Warn("could not repair task state after ceiling deferral", + zap.String("task_id", taskID), zap.Error(err)) + return + } + if updated { + s.logger.Zap().Info("repaired task state after ceiling deferral", + zap.String("task_id", taskID), zap.String("kind", string(deferral.Kind))) + } +} + // writeCeilingDeferralUpdate writes an updated deferral over the record that // produced it, clearing the AC-49 surface stamps explicitly: CeilingRecordKeys // does not emit them at all, so without this a stale ceiling_surface_written_at diff --git a/apps/backend/internal/orchestrator/ceiling_defer_test.go b/apps/backend/internal/orchestrator/ceiling_defer_test.go index 1ac134cd8c..1f5935d6e6 100644 --- a/apps/backend/internal/orchestrator/ceiling_defer_test.go +++ b/apps/backend/internal/orchestrator/ceiling_defer_test.go @@ -4,8 +4,10 @@ import ( "context" "errors" "testing" + "time" "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" ) // TestDeferCeilingRefusalCreatesRecordFromAbsent covers the first refusal for a @@ -41,6 +43,41 @@ func TestDeferCeilingRefusalCreatesRecordFromAbsent(t *testing.T) { } } +func TestReconcileQueuedTaskStateRepairsLegacyInProgressRow(t *testing.T) { + svc, repo := newServiceWithRealRepo(t) + taskRepo := newMockTaskRepo() + seedMockTaskState(taskRepo, "defer-state-repair", v1.TaskStateInProgress) + svc.taskRepo = taskRepo + ctx := context.Background() + queuedAt := time.Now().UTC() + if err := repo.CreateTask(ctx, &models.Task{ + ID: "defer-state-repair", Title: "T", State: v1.TaskStateInProgress, + CreatedAt: queuedAt, UpdatedAt: queuedAt, + }); err != nil { + t.Fatalf("CreateTask: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "defer-state-repair-session", TaskID: "defer-state-repair", + State: models.TaskSessionStateCreated, StartedAt: queuedAt, UpdatedAt: queuedAt, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + if err := repo.SetTaskMetadataKey(ctx, "defer-state-repair", models.MetaKeyDeferredLaunch, + models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{metaKeySessionID: "defer-state-repair-session"}, + Origin: string(launchOriginAutomatic), ReasonCode: ceilingReasonRefused, + QueuedAt: queuedAt, Ceiling: 5, Population: 6, PopulationKnown: true, + })); err != nil { + t.Fatalf("SetTaskMetadataKey: %v", err) + } + + svc.reconcileQueuedTaskState(ctx, "defer-state-repair") + if taskRepo.updatedStates["defer-state-repair"] != v1.TaskStateScheduling { + t.Fatalf("state repair write = %q, want %q", taskRepo.updatedStates["defer-state-repair"], v1.TaskStateScheduling) + } +} + // TestDeferCeilingRefusalMergesOntoExistingWIPIntent pins AC-46a/AC-12b: a // ceiling refusal must not clobber a pre-existing start_when_unblocked intent. func TestDeferCeilingRefusalMergesOntoExistingWIPIntent(t *testing.T) { diff --git a/apps/backend/internal/orchestrator/ceiling_entry.go b/apps/backend/internal/orchestrator/ceiling_entry.go new file mode 100644 index 0000000000..d027c14276 --- /dev/null +++ b/apps/backend/internal/orchestrator/ceiling_entry.go @@ -0,0 +1,890 @@ +package orchestrator + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + "go.uber.org/zap" +) + +type ceilingEntryBindingContextKey struct{} +type ceilingEntryKindContextKey struct{} + +const ( + legacyWorkflowEntryIdentity = "legacy:unknown" + ceilingEntryDetailStepChanged = "workflow destination step changed" + ceilingEntryDetailWorkflowChanged = "workflow destination workflow changed" +) + +// withCeilingEntryBinding carries the immutable workflow-entry identity through +// replay helpers that otherwise use the public launch APIs. The context value +// is only an internal guard; the durable payload remains the source of truth. +func withCeilingEntryBinding(ctx context.Context, binding *models.CeilingWorkflowEntryBinding) context.Context { + if binding == nil { + return ctx + } + copyOfBinding := *binding + return context.WithValue(ctx, ceilingEntryBindingContextKey{}, copyOfBinding) +} + +func ceilingEntryBindingFromContext(ctx context.Context) *models.CeilingWorkflowEntryBinding { + if ctx == nil { + return nil + } + binding, ok := ctx.Value(ceilingEntryBindingContextKey{}).(models.CeilingWorkflowEntryBinding) + if !ok || !binding.Valid() { + return nil + } + return &binding +} + +// withCeilingEntryBindingForSession preserves the committed workflow-entry +// identity when a terminal destination is replaced after admission. The +// route/entry fields remain immutable; only the newly allocated destination +// session is allowed to change. +func withCeilingEntryBindingForSession(ctx context.Context, sessionID string) context.Context { + binding := ceilingEntryBindingFromContext(ctx) + if binding == nil || sessionID == "" || binding.DestinationSessionID == sessionID { + return ctx + } + updated := *binding + updated.DestinationSessionID = sessionID + return withCeilingEntryBinding(ctx, &updated) +} + +func withCeilingEntryKind(ctx context.Context, kind models.CeilingLaunchKind) context.Context { + return context.WithValue(ctx, ceilingEntryKindContextKey{}, kind) +} + +func ceilingEntryKindFromContext(ctx context.Context) models.CeilingLaunchKind { + if ctx == nil { + return "" + } + kind, _ := ctx.Value(ceilingEntryKindContextKey{}).(models.CeilingLaunchKind) + return kind +} + +type ceilingEntryDisposition int + +const ( + ceilingEntryValid ceilingEntryDisposition = iota + ceilingEntrySuperseded + ceilingEntryUnavailable +) + +// ErrCeilingLaunchSuperseded is returned after a replay claim when the +// workflow entry or destination changed before runtime admission. The caller +// gives the old record a terminal disposition instead of retargeting it. +var ErrCeilingLaunchSuperseded = fmt.Errorf("ceiling deferred workflow entry was superseded") + +func cloneCeilingPayload(payload map[string]interface{}) map[string]interface{} { + if payload == nil { + return map[string]interface{}{} + } + clone := make(map[string]interface{}, len(payload)+1) + for key, value := range payload { + clone[key] = value + } + return clone +} + +func ceilingEntryBindingValue(binding models.CeilingWorkflowEntryBinding) map[string]interface{} { + value := map[string]interface{}{ + "workflow_id": binding.WorkflowID, + "destination_step_id": binding.DestinationStepID, + "route_operation_id": binding.RouteOperationID, + "entry_identity": binding.EntryIdentity, + } + if binding.DestinationSessionID != "" { + value["destination_session_id"] = binding.DestinationSessionID + } + return value +} + +// deriveCeilingEntryBinding obtains the binding from the task's committed or +// prepared route, or from the immutable workflow-step entry identity when the +// route is not materialized yet. It deliberately does not consult workflow +// history: the route or transition ledger is the task-owned admission contract +// and is the only identity replay may safely compare. +// +//nolint:cyclop,funlen // Binding resolution validates independent route, workflow, and recipient identities. +func (s *Service) deriveCeilingEntryBinding( + ctx context.Context, + task *models.Task, + payload map[string]interface{}, + sessionID string, +) (models.CeilingWorkflowEntryBinding, bool) { + if task == nil { + return models.CeilingWorkflowEntryBinding{}, false + } + if binding, present, err := models.ReadCeilingWorkflowEntryBinding(payload); err != nil { + return models.CeilingWorkflowEntryBinding{}, false + } else if present { + return binding, true + } + + requestedStepID := stringField(payload, metaKeyWorkflowStepID) + if requestedStepID == "" { + requestedStepID = task.WorkflowStepID + } + if requestedStepID == "" { + return models.CeilingWorkflowEntryBinding{}, false + } + + route, ok := models.LoadWorkflowSessionRoute(task.Metadata) + if ok && (route.EntryIdentity == "" || route.OperationID == "" || route.DestinationStepID != requestedStepID) { + return models.CeilingWorkflowEntryBinding{}, false + } + + var step *wfmodels.WorkflowStep + if s.workflowStepGetter != nil { + step, _ = s.workflowStepGetter.GetStep(ctx, requestedStepID) + if step == nil { + return models.CeilingWorkflowEntryBinding{}, false + } + } + workflowID := task.WorkflowID + if step != nil && step.WorkflowID != "" { + workflowID = step.WorkflowID + } + if workflowID == "" { + return models.CeilingWorkflowEntryBinding{}, false + } + + entryID := int64Field(payload, "workflow_entry_id") + entryIdentity := s.workflowEntryIdentity(ctx, task.ID, entryID) + if ok { + // A route already carries the authoritative identity. A supplied + // transition id must still agree with it, otherwise this payload belongs + // to a different entry than the route currently committed on the task. + if entryID > 0 && route.EntryIdentity != entryIdentity { + return models.CeilingWorkflowEntryBinding{}, false + } + entryIdentity = route.EntryIdentity + } + if entryIdentity == legacyWorkflowEntryIdentity { + return models.CeilingWorkflowEntryBinding{}, false + } + + destinationSessionID := sessionID + if ok && route.DestinationID != "" { + destinationSessionID = route.DestinationID + } + if expectedSessionID := sessionIDFromPayload(payload); expectedSessionID != "" { + if destinationSessionID != "" && destinationSessionID != expectedSessionID { + return models.CeilingWorkflowEntryBinding{}, false + } + destinationSessionID = expectedSessionID + } + + operationID := "" + if ok { + operationID = route.OperationID + } else { + var target *wfmodels.WorkflowSessionTarget + if step != nil { + target = step.SessionTarget + } + operationID = workflowSessionRouteID( + task.ID, + requestedStepID, + entryIdentity, + target, + s.resolveStepProfileSessionStartPolicy(step), + ) + } + return models.CeilingWorkflowEntryBinding{ + WorkflowID: workflowID, + DestinationStepID: requestedStepID, + RouteOperationID: operationID, + EntryIdentity: entryIdentity, + DestinationSessionID: destinationSessionID, + }, true +} + +// workflowEntryBindingForStep creates the binding used by workflow entry +// dispatch before a session route exists. A transition entry id is preferred; +// the repository's latest transition or the task creation stamp is the bounded +// fallback for legacy/manual entry paths. +func (s *Service) workflowEntryBindingForStep( + ctx context.Context, + taskID string, + step *wfmodels.WorkflowStep, + sessionID string, + entryIDs ...int64, +) (*models.CeilingWorkflowEntryBinding, bool) { + if taskID == "" || step == nil || step.ID == "" || step.WorkflowID == "" { + return nil, false + } + task, err := s.repo.GetTask(ctx, taskID) + if err != nil || task == nil { + return nil, false + } + // A zero entry id is the legacy processOnEnter path. Without a committed + // route it has no durable entry identity that can safely bind a dispatch; + // do not synthesize one from the task creation timestamp and then make every + // ordinary workflow prompt look like a replay-owned launch. + if len(entryIDs) == 0 || entryIDs[0] <= 0 { + if _, routePresent := models.LoadWorkflowSessionRoute(task.Metadata); !routePresent { + return nil, false + } + } + payload := map[string]interface{}{ + metaKeyWorkflowStepID: step.ID, + } + if len(entryIDs) > 0 && entryIDs[0] > 0 { + payload["workflow_entry_id"] = entryIDs[0] + } + binding, ok := s.deriveCeilingEntryBinding(ctx, task, payload, sessionID) + if !ok { + return nil, false + } + return &binding, true +} + +// workflowEntryDispatchIsCurrent rejects a late on_enter callback before it +// can build or send a prompt for a successor route. Binding derivation is +// intentionally best-effort for legacy callers, so a failed derivation alone +// must not be interpreted as permission to dispatch an old callback. +func (s *Service) workflowEntryDispatchIsCurrent( + ctx context.Context, + taskID string, + step *wfmodels.WorkflowStep, + entryIDs ...int64, +) bool { + return s.workflowEntryDispatchIsCurrentForSession(ctx, taskID, "", step, entryIDs...) +} + +func (s *Service) workflowEntryDispatchIsCurrentForSession( + ctx context.Context, + taskID, sessionID string, + step *wfmodels.WorkflowStep, + entryIDs ...int64, +) bool { + if s == nil || s.repo == nil || taskID == "" || step == nil { + return false + } + task, err := s.repo.GetTask(ctx, taskID) + if err != nil || task == nil { + if err != nil { + s.logger.Warn("workflow entry dispatch skipped because task ownership could not be read", + zap.String("task_id", taskID), zap.Error(err)) + } + return false + } + route, routePresent := models.LoadWorkflowSessionRoute(task.Metadata) + if !routePresent { + // A present but malformed route is an ownership read failure, not a + // legacy task with no route. Do not let a late callback fall back to the + // task's mutable step fields in that case. + if _, metadataRoutePresent := task.Metadata[models.MetaKeyWorkflowSessionRoute]; metadataRoutePresent { + s.logger.Info("workflow entry dispatch skipped because the committed route is invalid", + zap.String("task_id", taskID)) + return false + } + // Legacy/manual callbacks have no route to compare. The step-entry ledger + // already owns the callback's exact entry id. Verify both the current task + // step and the latest ledger row before preserving this compatibility path. + if len(entryIDs) > 0 && entryIDs[0] > 0 && + !s.workflowEntryDispatchMatchesCurrentTaskEntry(ctx, task, step, entryIDs...) { + s.logger.Info("workflow entry dispatch skipped after task entry changed", + zap.String("task_id", taskID), + zap.String("callback_step_id", step.ID), + ) + return false + } + return true + } + return s.workflowEntryDispatchRouteIsCurrent(ctx, taskID, task, route, sessionID, step, entryIDs...) +} + +func (s *Service) workflowEntryDispatchRouteIsCurrent( + ctx context.Context, + taskID string, + task *models.Task, + route models.WorkflowSessionRoute, + sessionID string, + step *wfmodels.WorkflowStep, + entryIDs ...int64, +) bool { + if task.WorkflowID != "" && step.WorkflowID != "" && task.WorkflowID != step.WorkflowID { + s.logger.Info("workflow entry dispatch skipped after workflow changed", + zap.String("task_id", taskID), + zap.String("workflow_id", task.WorkflowID), + zap.String("callback_workflow_id", step.WorkflowID), + ) + return false + } + // A workflow transition commits the task step before the selected session + // route is replaced. The callback for that exact transition is therefore + // allowed to replace the previous route; a callback for an older transition + // still fails the task-step and ledger-identity checks below. + if s.workflowEntryDispatchMayReplaceRoute(ctx, task, route, sessionID, step, entryIDs...) { + return true + } + if route.Phase == workflowSessionRouteCommitted && route.DestinationID == "" { + s.logger.Info("workflow entry dispatch skipped because the committed route has no destination", + zap.String("task_id", taskID)) + return false + } + if route.DestinationStepID != step.ID { + s.logger.Info("workflow entry dispatch skipped after destination route changed", + zap.String("task_id", taskID), + zap.String("route_step_id", route.DestinationStepID), + zap.String("callback_step_id", step.ID), + ) + return false + } + if sessionID != "" && route.DestinationID != "" && route.DestinationID != sessionID { + s.logger.Info("workflow entry dispatch skipped after destination session changed", + zap.String("task_id", taskID), + zap.String("route_session_id", route.DestinationID), + zap.String("callback_session_id", sessionID), + ) + return false + } + return s.workflowEntryDispatchIdentityIsCurrent(taskID, route, entryIDs...) +} + +func (s *Service) workflowEntryDispatchMayReplaceRoute( + ctx context.Context, + task *models.Task, + route models.WorkflowSessionRoute, + sessionID string, + step *wfmodels.WorkflowStep, + entryIDs ...int64, +) bool { + if !s.workflowEntryDispatchMatchesCurrentTaskEntry(ctx, task, step, entryIDs...) { + return false + } + return route.DestinationStepID != step.ID || + !workflowEntryDispatchIdentityMatches(route, entryIDs...) || + (sessionID != "" && route.DestinationID != "" && route.DestinationID != sessionID) +} + +func (s *Service) workflowEntryDispatchMatchesCurrentTaskEntry( + ctx context.Context, + task *models.Task, + step *wfmodels.WorkflowStep, + entryIDs ...int64, +) bool { + if task == nil || step == nil || task.WorkflowStepID != step.ID || len(entryIDs) == 0 || entryIDs[0] <= 0 { + return false + } + if task.WorkflowStepTransitionID > 0 { + return entryIDs[0] == task.WorkflowStepTransitionID + } + currentIdentity := s.workflowEntryIdentity(ctx, task.ID) + return currentIdentity != legacyWorkflowEntryIdentity && + currentIdentity == workflowEntryIdentityForID(entryIDs[0]) +} + +func workflowEntryIdentityForID(entryID int64) string { + return fmt.Sprintf("entry:%020d", entryID) +} + +func workflowEntryDispatchIdentityMatches(route models.WorkflowSessionRoute, entryIDs ...int64) bool { + if len(entryIDs) == 0 || entryIDs[0] <= 0 || route.EntryIdentity == "" { + return false + } + return route.EntryIdentity == workflowEntryIdentityForID(entryIDs[0]) +} + +func (s *Service) workflowEntryDispatchIdentityIsCurrent( + taskID string, + route models.WorkflowSessionRoute, + entryIDs ...int64, +) bool { + if len(entryIDs) == 0 || entryIDs[0] <= 0 { + return true + } + if route.EntryIdentity == "" { + s.logger.Info("workflow entry dispatch skipped because the committed route has no entry identity", + zap.String("task_id", taskID)) + return false + } + expectedIdentity := workflowEntryIdentityForID(entryIDs[0]) + if route.EntryIdentity == expectedIdentity { + return true + } + s.logger.Info("workflow entry dispatch skipped after entry identity changed", + zap.String("task_id", taskID), + zap.String("route_entry_identity", route.EntryIdentity), + zap.String("callback_entry_identity", expectedIdentity), + ) + return false +} + +func sessionIDFromPayload(payload map[string]interface{}) string { + return stringField(payload, metaKeySessionID) +} + +func ceilingPayloadHasWorkflowEntryHint(payload map[string]interface{}) bool { + return stringField(payload, metaKeyWorkflowStepID) != "" || + int64Field(payload, "workflow_entry_id") > 0 +} + +// ceilingDeferralTargetsSession resolves the exact recipient of a deferred +// launch when a caller already has a session identity. Session-backed launch +// kinds carry that identity in their payload. Seam 1's sessionless start kind +// instead obtains it from the committed workflow route, when one exists. A +// caller must not claim a sessionless record merely because it happens to be +// the only record on the task: that would let Send Now consume a successor or +// an unrelated start after the route changed. +func (s *Service) ceilingDeferralTargetsSession( + ctx context.Context, + taskID string, + deferral models.CeilingDeferral, + expectedSessionID string, +) (bool, error) { + if expectedSessionID == "" { + return true, nil + } + if destinationID := sessionIDFromCeilingPayload(deferral); destinationID != "" { + return destinationID == expectedSessionID, nil + } + if deferral.Kind != models.CeilingLaunchStart { + return false, nil + } + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + return false, err + } + if task == nil { + return false, nil + } + route, routePresent := models.LoadWorkflowSessionRoute(task.Metadata) + if !routePresent || route.DestinationID == "" { + return false, nil + } + if binding, present, bindingErr := models.ReadCeilingWorkflowEntryBinding(deferral.Payload); bindingErr != nil { + return false, nil + } else if present && + (binding.DestinationSessionID != "" && binding.DestinationSessionID != route.DestinationID || + binding.RouteOperationID != route.OperationID || + binding.DestinationStepID != route.DestinationStepID || + binding.EntryIdentity != route.EntryIdentity) { + return false, nil + } + return route.DestinationID == expectedSessionID, nil +} + +// enrichCeilingLaunchPayload adds a route binding when the route is already +// available. Failure to derive one is intentionally non-fatal for a generic +// launch; replay then applies the legacy ambiguity guard. +func (s *Service) enrichCeilingLaunchPayload( + ctx context.Context, + taskID string, + sessionID string, + payload map[string]interface{}, +) map[string]interface{} { + if _, present, err := models.ReadCeilingWorkflowEntryBinding(payload); err != nil { + return payload + } else if present { + return payload + } + if binding := ceilingEntryBindingFromContext(ctx); binding != nil { + updated := cloneCeilingPayload(payload) + updated[models.CeilingLaunchEntryBindingKey] = ceilingEntryBindingValue(*binding) + return updated + } + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + return payload + } + if !ceilingPayloadHasWorkflowEntryHint(payload) { + if task == nil { + return payload + } + if _, routePresent := models.LoadWorkflowSessionRoute(task.Metadata); !routePresent { + // A session-backed launch without an explicit workflow entry marker is + // a legacy/generic launch. Do not infer workflow ownership from the + // task's current step and turn it into a bound record that cannot be + // validated after a route is absent. + return payload + } + } + binding, ok := s.deriveCeilingEntryBinding(ctx, task, payload, sessionID) + if !ok { + return payload + } + updated := cloneCeilingPayload(payload) + updated[models.CeilingLaunchEntryBindingKey] = ceilingEntryBindingValue(binding) + return updated +} + +// enrichCeilingDeferralBinding performs a conditional payload-only update for +// legacy records. Queue timestamp and all other record identity remain exactly +// as stored. A concurrent successor wins the CAS and is returned to the caller +// for fresh validation. +// +//nolint:cyclop,nestif,gocognit // Binding enrichment keeps compare-and-set ownership adjacent to the legacy fallback. +func (s *Service) enrichCeilingDeferralBinding( + ctx context.Context, + task *models.Task, + deferral models.CeilingDeferral, +) (models.CeilingDeferral, error) { + taskID := "" + if task != nil { + taskID = task.ID + } + ctx, release := s.lockCeilingEntryAdmission(ctx, taskID) + defer release() + binding, present, err := models.ReadCeilingWorkflowEntryBinding(deferral.Payload) + if err != nil { + s.logger.Zap().Warn("could not decode deferred workflow entry binding; using legacy validation", + zap.String("task_id", taskID), zap.Error(err)) + return deferral, nil + } + needsWrite := false + if present { + // A seam-1 record may be written before an explicit session-target + // route commits its destination. Complete the optional destination id + // once that route is durable, without changing the entry identity. + if task != nil { + if route, routePresent := models.LoadWorkflowSessionRoute(task.Metadata); routePresent && + route.OperationID == binding.RouteOperationID && + route.DestinationStepID == binding.DestinationStepID && + route.EntryIdentity == binding.EntryIdentity && + route.DestinationID != "" && binding.DestinationSessionID == "" { + binding.DestinationSessionID = route.DestinationID + needsWrite = true + } + } + if !needsWrite { + return deferral, nil + } + } else { + if !ceilingPayloadHasWorkflowEntryHint(deferral.Payload) { + if task == nil { + return deferral, nil + } + if _, routePresent := models.LoadWorkflowSessionRoute(task.Metadata); !routePresent { + // A legacy session-backed record has no immutable workflow entry to + // enrich. Its exact session identity remains the admission guard. + return deferral, nil + } + } + binding, present = s.deriveCeilingEntryBinding(ctx, task, deferral.Payload, sessionIDFromCeilingPayload(deferral)) + if !present { + return deferral, nil + } + } + for attempt := 0; attempt < deferredLaunchCASRetryBudget; attempt++ { + record, prior, err := s.repo.GetTaskDeferredLaunch(ctx, task.ID) + if err != nil { + return deferral, err + } + current, err := models.ReadCeilingDeferral(record) + if err != nil { + return deferral, nil + } + equivalent, err := sameCeilingDeferralIdentity(current, deferral) + if err != nil { + return deferral, err + } + if !equivalent { + return deferral, fmt.Errorf("%w: deferred workflow entry changed while binding", ErrCeilingLaunchSuperseded) + } + updated := cloneCeilingRecord(record) + updatedPayload := cloneCeilingPayload(current.Payload) + updatedPayload[models.CeilingLaunchEntryBindingKey] = ceilingEntryBindingValue(binding) + updated[models.CeilingLaunchPayloadKey] = updatedPayload + stored, lostCompare, err := s.repo.SetTaskDeferredLaunchIfUnchanged(ctx, task.ID, prior, updated) + if err != nil { + return deferral, err + } + if stored { + current.Payload = updatedPayload + return current, nil + } + if !lostCompare { + return deferral, fmt.Errorf("repository reported no binding update") + } + } + return deferral, fmt.Errorf("workflow entry binding update retries exhausted") +} + +// ceilingDeferralsEquivalentForAdmission keeps a legacy record without the +// optional workflow binding equivalent to the same launch after the binding +// has become available. Once both records carry a binding, the binding is part +// of the identity and must compare exactly. +func ceilingDeferralsEquivalentForAdmission(a, b models.CeilingDeferral) (bool, error) { + left := cloneCeilingPayload(a.Payload) + right := cloneCeilingPayload(b.Payload) + _, leftBound := left[models.CeilingLaunchEntryBindingKey] + _, rightBound := right[models.CeilingLaunchEntryBindingKey] + if leftBound != rightBound { + delete(left, models.CeilingLaunchEntryBindingKey) + delete(right, models.CeilingLaunchEntryBindingKey) + } + a.Payload = left + b.Payload = right + return models.CeilingDeferralsEquivalent(a, b) +} + +// validateCeilingEntry compares a claimed record with the current task-owned +// route and destination. It is deliberately read-only. Callers retain the +// record on unavailable reads and only terminally dispose superseded entries. +func (s *Service) validateCeilingEntry( + ctx context.Context, + task *models.Task, + deferral models.CeilingDeferral, +) (ceilingEntryDisposition, string, error) { + return s.validateCeilingEntryWithDestinationState(ctx, task, deferral, true) +} + +// validateCeilingEntryWithDestinationState checks the task-owned workflow +// identity and, when requested, the destination's pre-dispatch state. The +// full check is used while a replay still owns a queued record. Dispatch +// helpers also carry the binding into their call graph, but those helpers may +// observe the destination as RUNNING after this launch has claimed it; they +// still need the route/entry guard without mistaking their own state change +// for a competing launch. +// +//nolint:cyclop,gocognit,nestif // Replay admission checks independent task, route, workflow, and destination invariants. +func (s *Service) validateCeilingEntryWithDestinationState( + ctx context.Context, + task *models.Task, + deferral models.CeilingDeferral, + checkDestinationState bool, +) (ceilingEntryDisposition, string, error) { + if task == nil { + return ceilingEntryUnavailable, "task is unavailable", nil + } + binding, present, err := models.ReadCeilingWorkflowEntryBinding(deferral.Payload) + if err != nil { + return ceilingEntryUnavailable, err.Error(), nil + } + route, routePresent := models.LoadWorkflowSessionRoute(task.Metadata) + payloadStepID := stringField(deferral.Payload, metaKeyWorkflowStepID) + workflowEntryID := int64Field(deferral.Payload, "workflow_entry_id") + workflowOrigin := present || payloadStepID != "" || workflowEntryID > 0 + if !present && routePresent && (deferral.Kind == models.CeilingLaunchStart || + (deferral.Kind == models.CeilingLaunchStartCreated && sessionIDFromCeilingPayload(deferral) != "")) { + workflowOrigin = true + } + if !present { + if !workflowOrigin { + return s.validateCeilingDestination(ctx, task, deferral, models.CeilingWorkflowEntryBinding{}, true), "", nil + } + if !routePresent || route.EntryIdentity == "" { + return ceilingEntryUnavailable, "workflow entry route is unavailable", nil + } + if payloadStepID != "" && route.DestinationStepID != payloadStepID { + return ceilingEntrySuperseded, ceilingEntryDetailStepChanged, nil + } + if payloadSessionID := sessionIDFromCeilingPayload(deferral); payloadSessionID != "" && + route.DestinationID != "" && route.DestinationID != payloadSessionID { + return ceilingEntrySuperseded, "workflow destination session changed", nil + } + // A legacy sessionless start has no recipient identity to compare with + // the current route. Replaying it against whichever destination happens + // to be current would retarget an old workflow entry, so only the newer + // bound form is eligible for this shape. + if deferral.Kind == models.CeilingLaunchStart && sessionIDFromCeilingPayload(deferral) == "" { + return ceilingEntryUnavailable, "legacy sessionless workflow entry is ambiguous", nil + } + derivedBinding, derived := s.deriveCeilingEntryBinding(ctx, task, deferral.Payload, sessionIDFromCeilingPayload(deferral)) + if !derived { + return ceilingEntryUnavailable, "workflow entry binding is ambiguous", nil + } + binding = derivedBinding + } + if !binding.Valid() { + return ceilingEntryUnavailable, "workflow entry binding is incomplete", nil + } + switch { + case routePresent && (route.OperationID != binding.RouteOperationID || + route.DestinationStepID != binding.DestinationStepID || + route.EntryIdentity != binding.EntryIdentity || + (route.DestinationID != "" && binding.DestinationSessionID != "" && route.DestinationID != binding.DestinationSessionID)): + return ceilingEntrySuperseded, "workflow destination route changed", nil + case !routePresent && present: + // Direct-profile workflow steps do not materialize a + // workflow_session_route. Their nested binding is still exact: the task + // must remain on the bound step and the latest transition ledger row must + // be the entry that created the deferred destination. Explicit session + // targets continue to require their committed route. + return s.validateUnroutedWorkflowEntry(ctx, task, deferral, binding, checkDestinationState) + case !routePresent && task.WorkflowStepID != binding.DestinationStepID && + deferral.Kind != models.CeilingLaunchWorkflowStepEnsure: + return ceilingEntrySuperseded, ceilingEntryDetailStepChanged, nil + } + if task.WorkflowID != "" && task.WorkflowID != binding.WorkflowID { + return ceilingEntrySuperseded, ceilingEntryDetailWorkflowChanged, nil + } + currentEntryIdentity := s.workflowEntryIdentity(ctx, task.ID) + if currentEntryIdentity != legacyWorkflowEntryIdentity && binding.EntryIdentity != currentEntryIdentity { + return ceilingEntrySuperseded, "workflow entry identity changed", nil + } + if s.workflowStepGetter != nil { + step, stepErr := s.workflowStepGetter.GetStep(ctx, binding.DestinationStepID) + if stepErr != nil { + return ceilingEntryUnavailable, "workflow destination step could not be read", stepErr + } + // Some legacy workflow-step providers return the step without its + // parent workflow id. The task/route binding remains authoritative in + // that case; only a populated, conflicting provider id supersedes it. + if step == nil || (step.WorkflowID != "" && step.WorkflowID != binding.WorkflowID) { + return ceilingEntrySuperseded, ceilingEntryDetailWorkflowChanged, nil + } + } + return s.validateCeilingDestination(ctx, task, deferral, binding, checkDestinationState), "", nil +} + +func (s *Service) validateUnroutedWorkflowEntry( + ctx context.Context, + task *models.Task, + deferral models.CeilingDeferral, + binding models.CeilingWorkflowEntryBinding, + checkDestinationState bool, +) (ceilingEntryDisposition, string, error) { + if s.workflowStepGetter == nil { + return ceilingEntryUnavailable, "workflow destination step cannot be read", nil + } + step, err := s.workflowStepGetter.GetStep(ctx, binding.DestinationStepID) + if err != nil { + return ceilingEntryUnavailable, "workflow destination step could not be read", err + } + if step == nil { + return ceilingEntrySuperseded, "workflow destination step no longer exists", nil + } + if step.SessionTarget != nil { + return ceilingEntryUnavailable, "workflow entry route is unavailable", nil + } + if task.WorkflowID != "" && task.WorkflowID != binding.WorkflowID { + return ceilingEntrySuperseded, ceilingEntryDetailWorkflowChanged, nil + } + if task.WorkflowStepID != binding.DestinationStepID { + return ceilingEntrySuperseded, ceilingEntryDetailStepChanged, nil + } + currentEntryIdentity := s.workflowEntryIdentity(ctx, task.ID) + if currentEntryIdentity == legacyWorkflowEntryIdentity { + return ceilingEntryUnavailable, "workflow entry identity is unavailable", nil + } + if currentEntryIdentity != binding.EntryIdentity { + return ceilingEntrySuperseded, "workflow entry identity changed", nil + } + expectedOperationID := workflowSessionRouteID( + task.ID, + binding.DestinationStepID, + binding.EntryIdentity, + step.SessionTarget, + s.resolveStepProfileSessionStartPolicy(step), + ) + if binding.RouteOperationID != expectedOperationID { + return ceilingEntrySuperseded, "workflow entry operation changed", nil + } + return s.validateCeilingDestination(ctx, task, deferral, binding, checkDestinationState), "", nil +} + +func (s *Service) validateCeilingDestination( + ctx context.Context, + task *models.Task, + deferral models.CeilingDeferral, + binding models.CeilingWorkflowEntryBinding, + checkDestinationState bool, +) ceilingEntryDisposition { + destinationID := binding.DestinationSessionID + if destinationID == "" { + destinationID = sessionIDFromCeilingPayload(deferral) + } + if destinationID == "" { + return ceilingEntryValid + } + session, err := s.repo.GetTaskSession(ctx, destinationID) + if err != nil || session == nil { + if err != nil { + return ceilingEntryUnavailable + } + return ceilingEntrySuperseded + } + if session.TaskID != task.ID || isTerminalSessionState(session.State) { + return ceilingEntrySuperseded + } + // A record waiting for capacity must never replay into a provider that has + // already started. That would duplicate a workflow prompt or continuation. + // Dispatch-time binding checks can opt out after this launch has itself + // claimed RUNNING; the task-owned route and session identity remain guarded. + if checkDestinationState { + if session.State == models.TaskSessionStateStarting || session.State == models.TaskSessionStateRunning { + return ceilingEntrySuperseded + } + } + return ceilingEntryValid +} + +//nolint:cyclop,nestif // The uncommitted-route compatibility path has to stay fenced before strict replay validation. +func (s *Service) validateClaimedCeilingBinding( + ctx context.Context, + taskID string, + binding *models.CeilingWorkflowEntryBinding, +) error { + if binding == nil { + return nil + } + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + return fmt.Errorf("reload task for deferred workflow entry: %w", err) + } + if task == nil { + return ErrCeilingLaunchSuperseded + } + kind := ceilingEntryKindFromContext(ctx) + if kind == "" { + // A normal on_enter callback can carry an in-memory binding before the + // route commit has been materialized. It is safe only when the task still + // names the same workflow step and latest entry; durable replay callers + // always set kind and take the strict committed-route path below. + if _, routePresent := models.LoadWorkflowSessionRoute(task.Metadata); !routePresent { + if _, metadataRoutePresent := task.Metadata[models.MetaKeyWorkflowSessionRoute]; metadataRoutePresent { + return fmt.Errorf("deferred workflow entry ownership is unavailable") + } + if task.WorkflowID != "" && task.WorkflowID != binding.WorkflowID { + return fmt.Errorf("%w: %s", ErrCeilingLaunchSuperseded, ceilingEntryDetailWorkflowChanged) + } + if task.WorkflowStepID != binding.DestinationStepID { + return fmt.Errorf("%w: %s", ErrCeilingLaunchSuperseded, ceilingEntryDetailStepChanged) + } + if disposition := s.validateCeilingDestination(ctx, task, models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + metaKeySessionID: binding.DestinationSessionID, + }, + }, *binding, false); disposition != ceilingEntryValid { + return fmt.Errorf("deferred workflow entry ownership is unavailable") + } + return nil + } + } + if kind == "" { + kind = models.CeilingLaunchStartCreated + } + disposition, detail, validationErr := s.validateCeilingEntryWithDestinationState(ctx, task, models.CeilingDeferral{ + Kind: kind, + Payload: map[string]interface{}{ + models.CeilingLaunchEntryBindingKey: ceilingEntryBindingValue(*binding), + metaKeySessionID: binding.DestinationSessionID, + }, + }, false) + if validationErr != nil { + return validationErr + } + if disposition == ceilingEntrySuperseded { + if detail == "" { + return ErrCeilingLaunchSuperseded + } + return fmt.Errorf("%w: %s", ErrCeilingLaunchSuperseded, detail) + } + if disposition == ceilingEntryUnavailable { + return fmt.Errorf("deferred workflow entry ownership is unavailable") + } + return nil +} + +func (s *Service) validateContextCeilingEntry(ctx context.Context, taskID string) error { + return s.validateClaimedCeilingBinding(ctx, taskID, ceilingEntryBindingFromContext(ctx)) +} diff --git a/apps/backend/internal/orchestrator/ceiling_entry_lock.go b/apps/backend/internal/orchestrator/ceiling_entry_lock.go new file mode 100644 index 0000000000..f24643c687 --- /dev/null +++ b/apps/backend/internal/orchestrator/ceiling_entry_lock.go @@ -0,0 +1,63 @@ +package orchestrator + +import "context" + +type ceilingEntryAdmissionLock struct { + mu chan struct{} + refs int +} + +type ceilingEntryAdmissionContextKey struct{} + +// acquireCeilingEntryAdmissionLock serializes operations that read or write a +// task's committed workflow route and its ceiling deferred-launch record. A +// channel is used instead of a mutex so the lock can be acquired with the same +// simple release shape as the other orchestrator guards. +func (s *Service) acquireCeilingEntryAdmissionLock(taskID string) func() { + if s == nil || taskID == "" { + return func() {} + } + s.ceilingEntryAdmissionLocksMu.Lock() + if s.ceilingEntryAdmissionLocks == nil { + s.ceilingEntryAdmissionLocks = make(map[string]*ceilingEntryAdmissionLock) + } + entry := s.ceilingEntryAdmissionLocks[taskID] + if entry == nil { + entry = &ceilingEntryAdmissionLock{mu: make(chan struct{}, 1)} + s.ceilingEntryAdmissionLocks[taskID] = entry + } + entry.refs++ + s.ceilingEntryAdmissionLocksMu.Unlock() + + entry.mu <- struct{}{} + return func() { + <-entry.mu + s.ceilingEntryAdmissionLocksMu.Lock() + entry.refs-- + if entry.refs == 0 { + delete(s.ceilingEntryAdmissionLocks, taskID) + } + s.ceilingEntryAdmissionLocksMu.Unlock() + } +} + +func ceilingEntryAdmissionLockHeld(ctx context.Context, taskID string) bool { + if ctx == nil || taskID == "" { + return false + } + owned, _ := ctx.Value(ceilingEntryAdmissionContextKey{}).(string) + return owned == taskID +} + +// lockCeilingEntryAdmission makes nested helpers re-entrant while keeping the +// ownership marker scoped to the context used inside the critical section. +func (s *Service) lockCeilingEntryAdmission(ctx context.Context, taskID string) (context.Context, func()) { + if taskID == "" || ceilingEntryAdmissionLockHeld(ctx, taskID) { + return ctx, func() {} + } + if ctx == nil { + ctx = context.Background() + } + release := s.acquireCeilingEntryAdmissionLock(taskID) + return context.WithValue(ctx, ceilingEntryAdmissionContextKey{}, taskID), release +} diff --git a/apps/backend/internal/orchestrator/ceiling_replay.go b/apps/backend/internal/orchestrator/ceiling_replay.go index 6f9160283b..eb8e314a03 100644 --- a/apps/backend/internal/orchestrator/ceiling_replay.go +++ b/apps/backend/internal/orchestrator/ceiling_replay.go @@ -40,6 +40,9 @@ const ( // the ceiling (AC-15d): the record is retained for a later pass and the // failure is logged, since — unlike a refusal — nothing else observes it. ceilingReplayFailed + // ceilingReplaySuperseded is a terminal disposition for a claimed launch + // whose workflow entry or destination changed before dispatch. + ceilingReplaySuperseded ) // drainDeferredCeilingLaunches is AC-15c's pass driver: one sweep tick walks @@ -62,13 +65,80 @@ func (s *Service) drainDeferredCeilingLaunches(ctx context.Context) { } } +// reconcileDeferredCeilingTaskState repairs the state written by older +// versions that left a valid, idle queued destination in REVIEW. It only moves +// REVIEW to SCHEDULING after authoritative queue and session checks; a read +// failure leaves the existing state untouched. +// +//nolint:cyclop // State repair validates queue ownership, route identity, and a final compare-and-set. +func (s *Service) reconcileDeferredCeilingTaskState( + ctx context.Context, task *models.Task, deferral models.CeilingDeferral, +) { + if task == nil || task.State != v1.TaskStateReview { + return + } + ctx, release := s.lockCeilingEntryAdmission(ctx, task.ID) + defer release() + observedDeferral, valid, err := s.readValidCeilingDeferredLaunch(ctx, task) + if err != nil || !valid { + return + } + if equivalent, compareErr := sameCeilingDeferralIdentity(observedDeferral, deferral); compareErr != nil || !equivalent { + return + } + if blockingSessionID, ok := s.otherWorkingSessionID(ctx, task.ID, ""); !ok || blockingSessionID != "" { + return + } + // The queue record and task route are independently mutable. Re-read both + // immediately before the state CAS so an older sweep snapshot cannot repair + // REVIEW after a successor launch replaced the record. + latestTask, taskErr := s.repo.GetTask(ctx, task.ID) + if taskErr != nil || latestTask == nil || latestTask.State != v1.TaskStateReview || + latestTask.IsFromOffice || taskArchived(latestTask) { + return + } + current, currentValid, readErr := s.readValidCeilingDeferredLaunch(ctx, latestTask) + if readErr != nil || !currentValid { + return + } + equivalent, compareErr := sameCeilingDeferralIdentity(current, observedDeferral) + if compareErr != nil || !equivalent { + return + } + updated, err := s.taskRepo.UpdateTaskStateIfCurrentIn( + ctx, task.ID, v1.TaskStateScheduling, []v1.TaskState{v1.TaskStateReview}, + ) + if err != nil { + s.logger.Zap().Warn("could not repair queued task state", + zap.String("task_id", task.ID), zap.Error(err)) + return + } + if updated { + s.logger.Zap().Info("repaired queued task state from REVIEW to SCHEDULING", + zap.String("task_id", task.ID), zap.String("kind", string(deferral.Kind))) + task.State = v1.TaskStateScheduling + } +} + // retryOneDeferredCeilingLaunch reads one task's deferral, evaluates AC-17b's // drop reasons, and otherwise dispatches to the kind's own replay. func (s *Service) retryOneDeferredCeilingLaunch(ctx context.Context, task *models.Task) { if task == nil || task.ID == "" { return } - raw, _ := task.Metadata[models.MetaKeyDeferredLaunch].(map[string]interface{}) + // The lister snapshot is only an index. Reload both task and record before + // admission so a later workflow entry cannot inherit the old destination. + freshTask, err := s.repo.GetTask(ctx, task.ID) + if err != nil || freshTask == nil { + return + } + task = freshTask + raw, _, err := s.repo.GetTaskDeferredLaunch(ctx, task.ID) + if err != nil { + s.logger.Zap().Warn("could not read deferred launch for retry sweep", + zap.String("task_id", task.ID), zap.Error(err)) + return + } deferral, err := models.ReadCeilingDeferral(raw) if err != nil { var unreplayable *models.UnreplayableCeilingRecordError @@ -81,25 +151,77 @@ func (s *Service) retryOneDeferredCeilingLaunch(ctx context.Context, task *model // task's fault; leave it for the next tick to see the current state. return } + deferral, err = s.enrichCeilingDeferralBinding(ctx, task, deferral) + if err != nil { + if errors.Is(err, ErrCeilingLaunchSuperseded) { + // A successor record won while this legacy record was being enriched. + // Leave the successor untouched for the next sweep; this pass must not + // retarget the old launch to it. + return + } + s.logger.Zap().Warn("could not bind deferred workflow entry for retry sweep", + zap.String("task_id", task.ID), zap.Error(err)) + return + } + + // A valid queued destination repairs the legacy REVIEW projection before a + // replay attempt. The repair itself is fail-closed on queue/route reads. + s.reconcileDeferredCeilingTaskState(ctx, task, deferral) if reasonCode, detail, drop := s.evaluateCeilingDropReasons(ctx, task, deferral); drop { s.dropCeilingDeferral(ctx, task, sessionIDFromCeilingPayload(deferral), deferral, reasonCode, detail) return } - switch s.replayCeilingDeferral(ctx, task, deferral) { + claim, found, claimErr := s.claimCeilingDeferredLaunch( + ctx, task.ID, sessionIDFromCeilingPayload(deferral), ceilingClaimOwnerReplay, + ) + if claimErr != nil { + s.logger.Zap().Warn("could not claim deferred launch for retry sweep", + zap.String("task_id", task.ID), zap.Error(claimErr)) + return + } + if !found || claim == nil { + // No matching record, or Send Now already owns it. + return + } + deferral = claim.deferral + + defer claim.releaseIfHeld(ctx) + currentTask, currentErr := s.repo.GetTask(ctx, task.ID) + if currentErr != nil || currentTask == nil { + return + } + if disposition, detail, _ := s.validateCeilingEntry(ctx, currentTask, claim.deferral); disposition != ceilingEntryValid { + if disposition == ceilingEntrySuperseded { + s.dropCeilingDeferral(ctx, currentTask, sessionIDFromCeilingPayload(claim.deferral), claim.deferral, + ceilingReasonSuperseded, detail) + } + return + } + + switch s.replayCeilingDeferral(ctx, currentTask, deferral) { case ceilingReplaySucceeded: - s.clearCeilingDeferredRecord(ctx, task.ID) + claim.settle(ctx) s.publishTaskUpdatedByID(ctx, task.ID) + case ceilingReplaySuperseded: + s.dropCeilingDeferral(ctx, task, sessionIDFromCeilingPayload(deferral), deferral, + ceilingReasonSuperseded, "workflow destination changed before dispatch") case ceilingReplayFailed: s.logger.Zap().Warn("ceiling retry replay failed for a non-ceiling reason; will retry on a later sweep", zap.String("task_id", task.ID), zap.String("kind", string(deferral.Kind))) case ceilingReplayStillDeferred: + // Remove the in-flight claim after the replay gate has restored the + // durable deferral. The next sweep must be able to own it again. + claim.releaseIfHeld(ctx) // Left exactly as stored: the replay's own admission gate already // re-persisted this same record via deferCeilingRefusal's // equivalence check (AC-32/AC-55a). AC-49g: retry the card note if // an earlier attempt to write it failed. s.attemptCeilingSurfaceWrite(ctx, task.ID) + // Refresh the task projection so the queue keeps the latest capacity + // observation while its durable recipient and queue time remain stable. + s.publishTaskUpdatedByID(ctx, task.ID) } } @@ -107,6 +229,11 @@ func (s *Service) retryOneDeferredCeilingLaunch(ctx context.Context, task *model // names, for the six kinds that carry one. CeilingLaunchStart carries none: // no session exists yet at seam 1. func sessionIDFromCeilingPayload(deferral models.CeilingDeferral) string { + if binding, present, err := models.ReadCeilingWorkflowEntryBinding(deferral.Payload); err == nil && present { + if binding.DestinationSessionID != "" { + return binding.DestinationSessionID + } + } if deferral.Kind == models.CeilingLaunchStart { return "" } @@ -148,6 +275,17 @@ func (s *Service) evaluateCeilingDropReasons( return ceilingReasonDroppedLaunchGateDeclined, "terminal PR auto-start gate declined the launch", true } } + if disposition, detail, validationErr := s.validateCeilingEntry(ctx, task, deferral); validationErr != nil { + // A route or step read error is uncertainty, not proof that the queued + // entry is obsolete. Preserve the record for a later sweep. + return "", "", false + } else if disposition == ceilingEntrySuperseded { + return ceilingReasonSuperseded, detail, true + } else if disposition == ceilingEntryUnavailable { + // Read uncertainty is not a terminal disposition. Keep the durable + // record for a later pass and leave the current task state untouched. + return "", "", false + } return "", "", false } @@ -178,14 +316,18 @@ func (s *Service) dropCeilingDeferral( } } - s.clearCeilingDeferredRecord(ctx, task.ID) + s.clearCeilingDeferredRecord(ctx, task.ID, deferral) s.publishTaskUpdatedByID(ctx, task.ID) } // clearCeilingDeferredRecord is AC-17's "clear the whole record" disposition, // applied on both a successful replay and a drop, under the same -// compare-and-set retry loop deferCeilingRefusal itself uses. -func (s *Service) clearCeilingDeferredRecord(ctx context.Context, taskID string) { +// compare-and-set retry loop deferCeilingRefusal itself uses. When an expected +// deferral is supplied, the clear is also bound to that exact record. A stale +// replay must not remove a newer successor that won the intervening CAS. +func (s *Service) clearCeilingDeferredRecord(ctx context.Context, taskID string, expected ...models.CeilingDeferral) { + ctx, release := s.lockCeilingEntryAdmission(ctx, taskID) + defer release() for attempt := 0; attempt < deferredLaunchCASRetryBudget; attempt++ { existingRaw, prior, err := s.repo.GetTaskDeferredLaunch(ctx, taskID) if err != nil { @@ -193,6 +335,23 @@ func (s *Service) clearCeilingDeferredRecord(ctx context.Context, taskID string) zap.String("task_id", taskID), zap.Error(err)) return } + if len(expected) > 0 { + current, readErr := models.ReadCeilingDeferral(existingRaw) + if readErr != nil { + return + } + equivalent, compareErr := sameCeilingDeferralIdentity(current, expected[0]) + if compareErr != nil { + s.logger.Zap().Warn("could not compare deferred launch while clearing a ceiling record", + zap.String("task_id", taskID), zap.Error(compareErr)) + return + } + if !equivalent { + s.logger.Zap().Info("leaving a newer ceiling deferred record in place after stale replay", + zap.String("task_id", taskID)) + return + } + } record := stripCeilingRecordKeys(existingRaw) stored, lostCompare, err := s.repo.SetTaskDeferredLaunchIfUnchanged(ctx, taskID, prior, record) if err != nil { @@ -212,6 +371,17 @@ func (s *Service) clearCeilingDeferredRecord(ctx context.Context, taskID string) zap.String("task_id", taskID)) } +// sameCeilingDeferralIdentity compares the stable identity of one queued +// launch. Capacity observations and surface bookkeeping may change while a +// record waits, but a replacement workflow entry must never inherit the state +// repair or dispatch of its predecessor. +func sameCeilingDeferralIdentity(a, b models.CeilingDeferral) (bool, error) { + if a.Origin != b.Origin || !a.QueuedAt.Equal(b.QueuedAt) { + return false, nil + } + return models.CeilingDeferralsEquivalent(a, b) +} + // stripCeilingRecordKeys removes every ceiling_-prefixed key from a // deferred_launch record, leaving any coexisting WIP-overflow or // dependency-chain intent untouched: the ceiling_ prefix (IsCeilingRecordKey) @@ -238,21 +408,45 @@ func stripCeilingRecordKeys(existing interface{}) map[string]interface{} { // deferral's kind. The closed set is exhaustive; a kind outside it was // already rejected as unreplayable by ReadCeilingDeferral before this point. func (s *Service) replayCeilingDeferral(ctx context.Context, task *models.Task, deferral models.CeilingDeferral) ceilingReplayOutcome { + if task == nil || task.ID == "" { + return ceilingReplayFailed + } + ctx, release := s.lockCeilingEntryAdmission(ctx, task.ID) + defer release() + current, err := s.repo.GetTask(ctx, task.ID) + if err != nil || current == nil { + return ceilingReplayFailed + } + disposition, _, validationErr := s.validateCeilingEntry(ctx, current, deferral) + if validationErr != nil { + return ceilingReplayFailed + } + if disposition == ceilingEntrySuperseded { + return ceilingReplaySuperseded + } + if disposition == ceilingEntryUnavailable { + return ceilingReplayFailed + } + replayCtx := ctx + if binding, present, bindingErr := models.ReadCeilingWorkflowEntryBinding(deferral.Payload); bindingErr == nil && present { + replayCtx = withCeilingEntryBinding(ctx, &binding) + } + replayCtx = withCeilingEntryKind(replayCtx, deferral.Kind) switch deferral.Kind { case models.CeilingLaunchStart: - return s.replayCeilingLaunchStart(ctx, task, deferral.Payload) + return s.replayCeilingLaunchStart(replayCtx, current, deferral.Payload) case models.CeilingLaunchStartCreated: - return s.replayCeilingLaunchStartCreated(ctx, task, deferral.Payload) + return s.replayCeilingLaunchStartCreated(replayCtx, current, deferral.Payload) case models.CeilingLaunchPromptEnsure: - return s.replayCeilingLaunchPromptEnsure(ctx, task, deferral.Payload) + return s.replayCeilingLaunchPromptEnsure(replayCtx, current, deferral.Payload) case models.CeilingLaunchWorkflowStepEnsure: - return s.replayCeilingLaunchWorkflowStepEnsure(ctx, task, deferral.Payload) + return s.replayCeilingLaunchWorkflowStepEnsure(replayCtx, current, deferral.Payload) case models.CeilingLaunchQueueDrainEnsure: - return s.replayCeilingLaunchQueueDrainEnsure(ctx, task, deferral.Payload) + return s.replayCeilingLaunchQueueDrainEnsure(replayCtx, current, deferral.Payload) case models.CeilingLaunchResume: - return s.replayCeilingLaunchResume(ctx, task, deferral.Payload) + return s.replayCeilingLaunchResume(replayCtx, current, deferral.Payload) case models.CeilingLaunchDynamicRelaunch: - return s.replayCeilingLaunchDynamicRelaunch(ctx, task, deferral.Payload) + return s.replayCeilingLaunchDynamicRelaunch(replayCtx, current, deferral.Payload) default: return ceilingReplayFailed } @@ -335,6 +529,10 @@ func (s *Service) replayCeilingLaunchStart(ctx context.Context, task *models.Tas decodeCeilingPayloadField(payload["additional_skill_slugs"], &additionalSkillSlugs) var entryOptions *workflowmove.EntryOptions decodeCeilingPayloadField(payload["entry_options"], &entryOptions) + var entryBinding *models.CeilingWorkflowEntryBinding + if binding, present, err := models.ReadCeilingWorkflowEntryBinding(payload); err == nil && present { + entryBinding = &binding + } env = s.remintCeilingLaunchCredentials(ctx, task.ID, env) @@ -350,7 +548,8 @@ func (s *Service) replayCeilingLaunchStart(ctx context.Context, task *models.Tas // an explicit automatic Origin override, and re-deriving from // auto_start alone would replay it as a manual override that bypasses // the ceiling. - Origin: launchOrigin(stringField(payload, "origin")), + Origin: launchOrigin(stringField(payload, "origin")), + ceilingEntryBinding: entryBinding, } if spawnRaw, ok := payload["spawn_origin"].(map[string]interface{}); ok { opts.SpawnOrigin = &SpawnOrigin{ @@ -390,6 +589,10 @@ func (s *Service) replayCeilingLaunchStartCreated(ctx context.Context, task *mod decodeCeilingPayloadField(payload[metaKeyAttachments], &attachments) var references []v1.EntityReference decodeCeilingPayloadField(payload["references"], &references) + var entryBinding *models.CeilingWorkflowEntryBinding + if binding, present, err := models.ReadCeilingWorkflowEntryBinding(payload); err == nil && present { + entryBinding = &binding + } options := startCreatedSessionOptions{ skipTaskDescriptionFallback: boolField(payload, "skip_task_description_fallback"), @@ -399,6 +602,7 @@ func (s *Service) replayCeilingLaunchStartCreated(ctx context.Context, task *mod includeCanvasGuidance: boolField(payload, "include_canvas_guidance"), promptReferencesPrepared: boolField(payload, "prompt_references_prepared"), preserveDirectPrompt: boolField(payload, "preserve_direct_prompt"), + ceilingEntryBinding: entryBinding, } execution, err := s.startCreatedSession( @@ -435,6 +639,9 @@ func (s *Service) replayCeilingLaunchPromptEnsure(ctx context.Context, task *mod fallbackLaunchPrompt: stringField(payload, "fallback_launch_prompt"), fallbackRetryPrompt: stringField(payload, "fallback_retry_prompt"), } + if binding, present, bindingErr := models.ReadCeilingWorkflowEntryBinding(payload); bindingErr == nil && present { + options.ceilingEntryBinding = &binding + } _, err := s.promptTask( ctx, task.ID, @@ -450,6 +657,9 @@ func (s *Service) replayCeilingLaunchPromptEnsure(ctx context.Context, task *mod if err == nil { return ceilingReplaySucceeded } + if errors.Is(err, ErrCeilingLaunchSuperseded) { + return ceilingReplaySuperseded + } if _, ok := isSeam3Refusal(err); ok { return ceilingReplayStillDeferred } @@ -473,6 +683,8 @@ func (s *Service) replayCeilingLaunchWorkflowStepEnsure(ctx context.Context, tas switch { case err == nil: return ceilingReplaySucceeded + case errors.Is(err, ErrCeilingLaunchSuperseded): + return ceilingReplaySuperseded case errors.Is(err, errSeam3WorkflowStepEnsureDeferred): return ceilingReplayStillDeferred default: @@ -486,7 +698,15 @@ func (s *Service) replayCeilingLaunchWorkflowStepEnsure(ctx context.Context, tas // itself uses. func (s *Service) replayCeilingLaunchQueueDrainEnsure(ctx context.Context, task *models.Task, payload map[string]interface{}) ceilingReplayOutcome { sessionID := stringField(payload, metaKeySessionID) - s.tryEnsureExecution(ctx, sessionID, seam3CallShapeQueueDrain, launchOriginAutomatic, stringField(payload, "queued_message_id")) + err := s.tryEnsureExecutionWithBinding(ctx, sessionID, seam3CallShapeQueueDrain, launchOriginAutomatic, stringField(payload, "queued_message_id"), ceilingEntryBindingFromContext(ctx)) + if errors.Is(err, ErrCeilingLaunchSuperseded) { + return ceilingReplaySuperseded + } + if err != nil { + if _, ok := isSeam3Refusal(err); !ok { + return ceilingReplayFailed + } + } session, err := s.repo.GetTaskSession(ctx, sessionID) if err != nil { @@ -522,11 +742,13 @@ func (s *Service) replayCeilingLaunchDynamicRelaunch(ctx context.Context, task * AgentExecutionID: stringField(payload, "agent_execution_id"), AgentProfileID: stringField(payload, metaKeyAgentProfileID), } - switch s.relaunchDynamicTaskAfterFailureOutcome(ctx, data, stringField(payload, "execution_profile_id"), launchOriginAutomatic) { + switch s.relaunchDynamicTaskAfterFailureOutcomeWithBinding(ctx, data, stringField(payload, "execution_profile_id"), launchOriginAutomatic, ceilingEntryBindingFromContext(ctx)) { case dynamicRelaunchSucceeded: return ceilingReplaySucceeded case dynamicRelaunchDeferred: return ceilingReplayStillDeferred + case dynamicRelaunchSuperseded: + return ceilingReplaySuperseded default: return ceilingReplayFailed } @@ -540,6 +762,8 @@ func (s *Service) replayCeilingLaunchDynamicRelaunch(ctx context.Context, task * // else is a non-ceiling failure. func ceilingReplayOutcomeFromExecution(execution *executor.TaskExecution, err error) ceilingReplayOutcome { switch { + case errors.Is(err, ErrCeilingLaunchSuperseded): + return ceilingReplaySuperseded case err != nil: return ceilingReplayFailed case execution == nil: diff --git a/apps/backend/internal/orchestrator/ceiling_replay_test.go b/apps/backend/internal/orchestrator/ceiling_replay_test.go index 90d8a01f1f..f6f5d05070 100644 --- a/apps/backend/internal/orchestrator/ceiling_replay_test.go +++ b/apps/backend/internal/orchestrator/ceiling_replay_test.go @@ -2,6 +2,8 @@ package orchestrator import ( "context" + "errors" + "sync" "testing" "time" @@ -15,6 +17,17 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" ) +type ceilingDeferredLaunchReadErrorRepo struct { + sessionExecutorStore + err error +} + +func (r ceilingDeferredLaunchReadErrorRepo) GetTaskDeferredLaunch( + context.Context, string, +) (map[string]interface{}, interface{}, error) { + return nil, nil, r.err +} + // --- pure unit tests ------------------------------------------------------- func TestStripCeilingRecordKeys_RemovesOnlyCeilingPrefixedKeys(t *testing.T) { @@ -85,6 +98,509 @@ func TestClearCeilingDeferredRecord_PreservesCoexistingWIPIntent(t *testing.T) { require.Equal(t, true, after[models.DeferredLaunchStartWhenUnblockedKey], "clearing the ceiling half must not disturb the WIP-overflow half") } +func TestClearCeilingDeferredRecord_DoesNotClearNewerSuccessor(t *testing.T) { + svc, repo := newServiceWithRealRepo(t) + ctx := context.Background() + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "clear-task-successor", Title: "t", State: v1.TaskStateInProgress, + CreatedAt: time.Now(), UpdatedAt: time.Now(), + })) + + original := models.CeilingDeferral{ + Kind: models.CeilingLaunchResume, + Payload: map[string]interface{}{metaKeySessionID: "session-original"}, + Origin: string(launchOriginAutomatic), + ReasonCode: ceilingReasonRefused, + QueuedAt: time.Now(), + } + successor := models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{metaKeySessionID: "session-successor"}, + Origin: string(launchOriginAutomatic), + ReasonCode: ceilingReasonRefused, + QueuedAt: original.QueuedAt.Add(time.Second), + } + _, _, err := repo.SetTaskDeferredLaunchIfUnchanged(ctx, "clear-task-successor", tasksqlite.AbsentDeferredLaunch(), models.CeilingRecordKeys(successor)) + require.NoError(t, err) + + svc.clearCeilingDeferredRecord(ctx, "clear-task-successor", original) + + after := deferredLaunchOf(t, svc, "clear-task-successor") + got, err := models.ReadCeilingDeferral(after) + require.NoError(t, err) + require.Equal(t, successor.Kind, got.Kind) + require.Equal(t, "session-successor", sessionIDFromCeilingPayload(got)) +} + +func TestClaimCeilingDeferredLaunchSerializesSendNowAndReplay(t *testing.T) { + svc, repo := newServiceWithRealRepo(t) + ctx := context.Background() + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "claim-task", Title: "t", State: v1.TaskStateScheduling, + CreatedAt: time.Now(), UpdatedAt: time.Now(), + })) + queuedAt := time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC) + deferral := models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{metaKeySessionID: "claim-session"}, + Origin: string(launchOriginAutomatic), ReasonCode: ceilingReasonRefused, + QueuedAt: queuedAt, Ceiling: 5, Population: 5, PopulationKnown: true, + } + record := models.CeilingRecordKeys(deferral) + record[models.DeferredLaunchStartWhenUnblockedKey] = true + _, _, err := repo.SetTaskDeferredLaunchIfUnchanged(ctx, "claim-task", tasksqlite.AbsentDeferredLaunch(), record) + require.NoError(t, err) + + first, found, err := svc.claimCeilingDeferredLaunch(ctx, "claim-task", "claim-session", ceilingClaimOwnerSendNow) + require.NoError(t, err) + require.True(t, found) + require.NotNil(t, first) + second, found, err := svc.claimCeilingDeferredLaunch(ctx, "claim-task", "claim-session", ceilingClaimOwnerReplay) + require.NoError(t, err) + require.True(t, found) + require.Nil(t, second, "a replay must not dispatch while Send Now owns the record") + + first.settle(ctx) + after := deferredLaunchOf(t, svc, "claim-task") + require.False(t, models.HasCeilingDeferredIntent(&models.Task{Metadata: after})) + require.Equal(t, true, after[models.DeferredLaunchStartWhenUnblockedKey]) +} + +func TestClaimCeilingDeferredLaunchReleasesAfterCapacityBookkeepingChanges(t *testing.T) { + svc, repo := newServiceWithRealRepo(t) + ctx := context.Background() + now := time.Now().UTC() + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "claim-bookkeeping", Title: "t", State: v1.TaskStateScheduling, + CreatedAt: now, UpdatedAt: now, + })) + deferral := models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{metaKeySessionID: "claim-bookkeeping-session"}, + Origin: string(launchOriginAutomatic), ReasonCode: ceilingReasonRefused, + QueuedAt: now, Ceiling: 5, Population: 6, PopulationKnown: true, + } + record := models.CeilingRecordKeys(deferral) + _, _, err := repo.SetTaskDeferredLaunchIfUnchanged(ctx, "claim-bookkeeping", tasksqlite.AbsentDeferredLaunch(), record) + require.NoError(t, err) + + claim, found, err := svc.claimCeilingDeferredLaunch( + ctx, "claim-bookkeeping", "claim-bookkeeping-session", ceilingClaimOwnerReplay, + ) + require.NoError(t, err) + require.True(t, found) + require.NotNil(t, claim) + + // Capacity observations are mutable queue bookkeeping, not a successor + // launch. A claim release must still remove its own marker after that write. + updatedRecord := deferredLaunchOf(t, svc, "claim-bookkeeping") + updatedRecord[models.CeilingReasonCodeKey] = "capacity_changed" + require.NoError(t, repo.SetTaskMetadataKey(ctx, "claim-bookkeeping", models.MetaKeyDeferredLaunch, updatedRecord)) + claim.releaseIfHeld(ctx) + + after := deferredLaunchOf(t, svc, "claim-bookkeeping") + _, _, claimed := models.ReadCeilingLaunchClaim(after) + require.False(t, claimed, "mutable capacity bookkeeping must not strand the in-flight claim") + decoded, err := models.ReadCeilingDeferral(after) + require.NoError(t, err) + require.Equal(t, "capacity_changed", decoded.ReasonCode) +} + +func TestClaimCeilingDeferredLaunchReclaimsExpiredClaim(t *testing.T) { + svc, repo := newServiceWithRealRepo(t) + ctx := context.Background() + now := time.Now().UTC() + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "claim-expired", Title: "t", State: v1.TaskStateScheduling, + CreatedAt: now, UpdatedAt: now, + })) + deferral := models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{metaKeySessionID: "claim-expired-session"}, + Origin: string(launchOriginAutomatic), ReasonCode: ceilingReasonRefused, + QueuedAt: now, Ceiling: 5, Population: 5, PopulationKnown: true, + } + record := models.CeilingRecordKeys(deferral) + record[models.CeilingLaunchClaimKey] = map[string]interface{}{ + "id": "abandoned-claim", "owner": ceilingClaimOwnerReplay, + "expires_at": now.Add(-time.Minute).Format(time.RFC3339Nano), + } + _, _, err := repo.SetTaskDeferredLaunchIfUnchanged(ctx, "claim-expired", tasksqlite.AbsentDeferredLaunch(), record) + require.NoError(t, err) + + claim, found, err := svc.claimCeilingDeferredLaunch(ctx, "claim-expired", "claim-expired-session", ceilingClaimOwnerSendNow) + require.NoError(t, err) + require.True(t, found) + require.NotNil(t, claim) + claimID, owner, ok := models.ReadCeilingLaunchClaim(deferredLaunchOf(t, svc, "claim-expired")) + require.True(t, ok) + require.NotEqual(t, "abandoned-claim", claimID) + require.Equal(t, ceilingClaimOwnerSendNow, owner) +} + +func TestCeilingClaimReleaseUsesDetachedContext(t *testing.T) { + svc, repo := newServiceWithRealRepo(t) + ctx := context.Background() + now := time.Now().UTC() + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "claim-cancelled", Title: "t", State: v1.TaskStateScheduling, + CreatedAt: now, UpdatedAt: now, + })) + deferral := models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{metaKeySessionID: "claim-cancelled-session"}, + Origin: string(launchOriginAutomatic), ReasonCode: ceilingReasonRefused, QueuedAt: now, + } + _, _, err := repo.SetTaskDeferredLaunchIfUnchanged(ctx, "claim-cancelled", tasksqlite.AbsentDeferredLaunch(), models.CeilingRecordKeys(deferral)) + require.NoError(t, err) + claim, found, err := svc.claimCeilingDeferredLaunch(ctx, "claim-cancelled", "claim-cancelled-session", ceilingClaimOwnerReplay) + require.NoError(t, err) + require.True(t, found) + require.NotNil(t, claim) + cancelledCtx, cancel := context.WithCancel(ctx) + cancel() + claim.releaseIfHeld(cancelledCtx) + _, _, claimed := models.ReadCeilingLaunchClaim(deferredLaunchOf(t, svc, "claim-cancelled")) + require.False(t, claimed, "a cancelled caller must not strand its durable claim") +} + +func TestReplayCeilingDeferralRejectsStaleWorkflowEntryBeforeDispatch(t *testing.T) { + svc, repo := newServiceWithRealRepo(t) + ctx := context.Background() + now := time.Now().UTC() + task := &models.Task{ + ID: "replay-stale-entry", + WorkflowID: "workflow-1", + WorkflowStepID: "step-new", + Title: "stale replay", + State: v1.TaskStateScheduling, + CreatedAt: now, + UpdatedAt: now, + Metadata: map[string]interface{}{models.MetaKeyWorkflowSessionRoute: models.WorkflowSessionRoute{ + OperationID: "route-new", + DestinationStepID: "step-new", + EntryIdentity: "entry:new", + TargetKind: "new_session", + DestinationID: "session-new", + Phase: "committed", + }}, + } + require.NoError(t, repo.CreateTask(ctx, task)) + + old := models.CeilingDeferral{ + Kind: models.CeilingLaunchStart, + Payload: map[string]interface{}{ + metaKeyPrompt: "old workflow prompt", + models.CeilingLaunchEntryBindingKey: map[string]interface{}{ + "workflow_id": "workflow-1", + "destination_step_id": "step-old", + "route_operation_id": "route-old", + "entry_identity": "entry:old", + "destination_session_id": "session-old", + }, + }, + Origin: string(launchOriginAutomatic), + ReasonCode: ceilingReasonRefused, + QueuedAt: now, + } + + // The replay caller may hold an old task snapshot. The method must reload + // and reject before any start/LaunchAgent side effect can use that snapshot. + outcome := svc.replayCeilingDeferral(ctx, &models.Task{ID: task.ID}, old) + require.Equal(t, ceilingReplaySuperseded, outcome) + reloaded, err := repo.GetTask(ctx, task.ID) + require.NoError(t, err) + route, ok := models.LoadWorkflowSessionRoute(reloaded.Metadata) + require.True(t, ok) + require.Equal(t, "route-new", route.OperationID) +} + +func TestReplayCeilingDeferralRejectsRouteChangedAfterInitialValidation(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSessionWithStep(t, repo, "replay-interleaving", "replay-interleaving-session", "step-old") + now := time.Now().UTC() + task, err := repo.GetTask(ctx, "replay-interleaving") + require.NoError(t, err) + entryIdentity := (&Service{repo: repo}).workflowEntryIdentity(ctx, task.ID) + oldRoute := models.WorkflowSessionRoute{ + OperationID: "route-old", + DestinationStepID: "step-old", + EntryIdentity: entryIdentity, + TargetKind: "new_session", + DestinationID: "replay-interleaving-session", + Phase: "committed", + } + newRoute := oldRoute + newRoute.OperationID = "route-new" + newRoute.DestinationStepID = "step-new" + newRoute.EntryIdentity = "entry:new" + require.NoError(t, repo.SetTaskMetadataKey(ctx, task.ID, models.MetaKeyWorkflowSessionRoute, oldRoute)) + + deferral := models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + metaKeySessionID: "replay-interleaving-session", + metaKeyPrompt: "old workflow prompt", + models.CeilingLaunchEntryBindingKey: map[string]interface{}{ + "workflow_id": "wf1", + "destination_step_id": "step-old", + "route_operation_id": "route-old", + "entry_identity": entryIdentity, + "destination_session_id": "replay-interleaving-session", + }, + }, + Origin: string(launchOriginAutomatic), ReasonCode: ceilingReasonRefused, + QueuedAt: now, + } + require.NoError(t, repo.SetTaskMetadataKey(ctx, task.ID, models.MetaKeyDeferredLaunch, models.CeilingRecordKeys(deferral))) + + stepGetter := newMockStepGetter() + stepGetter.steps["step-old"] = &wfmodels.WorkflowStep{ID: "step-old", WorkflowID: "wf1"} + var switchOnce sync.Once + stepGetter.getStepFunc = func(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error) { + if stepID == "step-old" { + switchOnce.Do(func() { + latest, getErr := repo.GetTask(ctx, task.ID) + require.NoError(t, getErr) + latest.Metadata[models.MetaKeyWorkflowSessionRoute] = newRoute + require.NoError(t, repo.UpdateTask(ctx, latest)) + }) + } + return stepGetter.steps[stepID], nil + } + taskRepo := newMockTaskRepo() + seedMockTaskState(taskRepo, task.ID, v1.TaskStateScheduling) + agentMgr := &mockAgentManager{repoForExecutionLookup: repo} + svc := createTestServiceWithScheduler(repo, stepGetter, taskRepo, agentMgr) + + // The old snapshot passes the first route comparison. The step read then + // commits a successor before the concrete start-created seam is admitted; + // that seam must re-read the route and reject before LaunchAgent. + outcome := svc.replayCeilingDeferral(ctx, &models.Task{ID: task.ID}, deferral) + require.Equal(t, ceilingReplaySuperseded, outcome) + agentMgr.mu.Lock() + launchCalls := len(agentMgr.setExecutionDescriptionCalls) + agentMgr.mu.Unlock() + require.Zero(t, launchCalls, "a stale replay must not dispatch the old workflow entry") + + reloaded, err := repo.GetTask(ctx, task.ID) + require.NoError(t, err) + route, ok := models.LoadWorkflowSessionRoute(reloaded.Metadata) + require.True(t, ok) + require.Equal(t, newRoute.OperationID, route.OperationID) +} + +func TestWorkflowEntryDispatchRejectsStaleCommittedRoute(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSessionWithStep(t, repo, "workflow-dispatch-stale", "workflow-dispatch-session", "step-current") + require.NoError(t, repo.SetTaskMetadataKey(ctx, "workflow-dispatch-stale", models.MetaKeyWorkflowSessionRoute, + models.WorkflowSessionRoute{ + OperationID: "route-current", + DestinationStepID: "step-current", + EntryIdentity: "entry:00000000000000000042", + TargetKind: "new_session", + DestinationID: "workflow-dispatch-session", + Phase: "committed", + })) + + step := &wfmodels.WorkflowStep{ID: "step-current", WorkflowID: "wf1", Name: "Current"} + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + + require.False(t, svc.workflowEntryDispatchIsCurrent(ctx, "workflow-dispatch-stale", step, int64(41)), + "a late callback for an older entry must not dispatch against the committed successor route") + require.True(t, svc.workflowEntryDispatchIsCurrent(ctx, "workflow-dispatch-stale", step, int64(42))) + require.NoError(t, repo.SetTaskMetadataKey(ctx, "workflow-dispatch-stale", models.MetaKeyWorkflowSessionRoute, + models.WorkflowSessionRoute{ + OperationID: "route-current", + DestinationStepID: "step-current", + EntryIdentity: "entry:00000000000000000042", + TargetKind: "new_session", + DestinationID: "successor-session", + Phase: "committed", + })) + require.False(t, svc.workflowEntryDispatchIsCurrentForSession( + ctx, "workflow-dispatch-stale", "workflow-dispatch-session", step, int64(42), + ), "a callback for a replaced destination must not dispatch the old session") +} + +func TestValidateCeilingEntryAllowsDirectProfileWorkflowEntryWithoutRoute(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + taskID := "workflow-direct-profile" + sessionID := "workflow-direct-profile-session" + stepID := "workflow-direct-profile-step" + seedTaskAndSessionWithStep(t, repo, taskID, sessionID, stepID) + + stepGetter := newMockStepGetter() + stepGetter.steps[stepID] = &wfmodels.WorkflowStep{ + ID: stepID, + WorkflowID: "wf1", + AgentProfileID: "profile-direct", + } + svc := createTestService(repo, stepGetter, newMockTaskRepo()) + task, err := repo.GetTask(ctx, taskID) + require.NoError(t, err) + entryIdentity := svc.workflowEntryIdentity(ctx, taskID) + binding := models.CeilingWorkflowEntryBinding{ + WorkflowID: "wf1", + DestinationStepID: stepID, + RouteOperationID: workflowSessionRouteID(taskID, stepID, entryIdentity, nil, svc.resolveStepProfileSessionStartPolicy(stepGetter.steps[stepID])), + EntryIdentity: entryIdentity, + DestinationSessionID: sessionID, + } + + deferral := models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + metaKeySessionID: sessionID, + metaKeyWorkflowStepID: stepID, + models.CeilingLaunchEntryBindingKey: ceilingEntryBindingValue(binding), + }, + Origin: string(launchOriginAutomatic), + ReasonCode: ceilingReasonRefused, + QueuedAt: time.Now().UTC(), + } + + disposition, detail, validationErr := svc.validateCeilingEntry(ctx, task, deferral) + require.NoError(t, validationErr) + require.Equal(t, ceilingEntryValid, disposition, detail) + + // A successor transition changes the latest ledger identity even when the + // old destination has no materialized workflow_session_route. The old + // record must become terminal instead of being retargeted by task fields. + task.WorkflowStepID = "workflow-direct-profile-successor" + task.UpdatedAt = time.Now().UTC() + require.NoError(t, repo.UpdateTaskPreservingDeferredLaunch(ctx, task)) + latest, err := repo.GetTask(ctx, taskID) + require.NoError(t, err) + disposition, detail, validationErr = svc.validateCeilingEntry(ctx, latest, deferral) + require.NoError(t, validationErr) + require.Equal(t, ceilingEntrySuperseded, disposition, detail) +} + +func TestValidateCeilingEntryAllowsPendingWorkflowStepEnsureOnlyForCurrentEntry(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSessionWithStep(t, repo, "workflow-step-pending", "workflow-step-session", "step-source") + task, err := repo.GetTask(ctx, "workflow-step-pending") + require.NoError(t, err) + entryIdentity := (&Service{repo: repo}).workflowEntryIdentity(ctx, task.ID) + require.NoError(t, repo.SetTaskMetadataKey(ctx, task.ID, models.MetaKeyWorkflowSessionRoute, models.WorkflowSessionRoute{ + OperationID: "route-target", + DestinationStepID: "step-target", + EntryIdentity: entryIdentity, + TargetKind: "new_session", + DestinationID: "workflow-step-session", + Phase: "committed", + })) + task, err = repo.GetTask(ctx, task.ID) + require.NoError(t, err) + + stepGetter := newMockStepGetter() + stepGetter.steps["step-target"] = &wfmodels.WorkflowStep{ID: "step-target", WorkflowID: "wf1"} + svc := createTestService(repo, stepGetter, newMockTaskRepo()) + binding, bound := svc.workflowEntryBindingForStep(ctx, task.ID, stepGetter.steps["step-target"], "workflow-step-session") + require.True(t, bound) + + deferral := models.CeilingDeferral{ + Kind: models.CeilingLaunchWorkflowStepEnsure, + Payload: map[string]interface{}{ + metaKeySessionID: "workflow-step-session", + metaKeyWorkflowStepID: "step-target", + models.CeilingLaunchEntryBindingKey: ceilingEntryBindingValue(*binding), + }, + Origin: string(launchOriginAutomatic), + QueuedAt: time.Now().UTC(), + } + + disposition, detail, validationErr := svc.validateCeilingEntry(ctx, task, deferral) + require.NoError(t, validationErr) + require.Equal(t, ceilingEntryValid, disposition, detail) + + // Admission of a newer entry changes both the task step and the ledger + // identity. The old queued transition must become terminal rather than + // being retargeted to the new step. + task.WorkflowStepID = "step-successor" + task.UpdatedAt = time.Now().UTC() + task.Metadata[models.MetaKeyWorkflowSessionRoute] = models.WorkflowSessionRoute{ + OperationID: "route-successor", + DestinationStepID: "step-successor", + EntryIdentity: "entry:successor", + TargetKind: "new_session", + DestinationID: "workflow-step-session", + Phase: "committed", + } + require.NoError(t, repo.UpdateTaskPreservingDeferredLaunch(ctx, task)) + + latest, err := repo.GetTask(ctx, task.ID) + require.NoError(t, err) + disposition, detail, validationErr = svc.validateCeilingEntry(ctx, latest, deferral) + require.NoError(t, validationErr) + require.Equal(t, ceilingEntrySuperseded, disposition, detail) +} + +func TestWriteTaskReviewStateFailsClosedWhenCeilingQueueReadFails(t *testing.T) { + baseService, repo := newServiceWithRealRepo(t) + ctx := context.Background() + now := time.Now().UTC() + task := &models.Task{ + ID: "review-read-error", Title: "read error", State: v1.TaskStateReview, + CreatedAt: now, UpdatedAt: now, + } + require.NoError(t, repo.CreateTask(ctx, task)) + baseTaskRepo := newMockTaskRepo() + seedMockTaskState(baseTaskRepo, task.ID, v1.TaskStateReview) + readErr := errors.New("deferred launch storage unavailable") + baseService.repo = ceilingDeferredLaunchReadErrorRepo{ + sessionExecutorStore: repo, + err: readErr, + } + baseService.taskRepo = baseTaskRepo + + baseService.writeTaskReviewState(ctx, task.ID, "") + + baseTaskRepo.mu.Lock() + writes := baseTaskRepo.stateWrites[task.ID] + baseTaskRepo.mu.Unlock() + require.Zero(t, writes, "queue uncertainty must not publish a REVIEW or Scheduling state") +} + +func TestWriteTaskReviewStateRepairsLegacyReviewQueuedCreatedSession(t *testing.T) { + svc, repo := newServiceWithRealRepo(t) + ctx := context.Background() + seedTaskAndSessionWithStep(t, repo, "review-legacy-queued", "review-legacy-session", "review-step") + require.NoError(t, repo.UpdateTaskState(ctx, "review-legacy-queued", v1.TaskStateReview)) + deferral := models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + metaKeySessionID: "review-legacy-session", + }, + Origin: string(launchOriginAutomatic), + ReasonCode: ceilingReasonRefused, + QueuedAt: time.Now().UTC(), + Ceiling: 5, + Population: 6, + PopulationKnown: true, + } + require.NoError(t, repo.SetTaskMetadataKey(ctx, "review-legacy-queued", models.MetaKeyDeferredLaunch, models.CeilingRecordKeys(deferral))) + taskRepo := newMockTaskRepo() + seedMockTaskState(taskRepo, "review-legacy-queued", v1.TaskStateReview) + svc.taskRepo = taskRepo + + task, err := repo.GetTask(ctx, "review-legacy-queued") + require.NoError(t, err) + svc.writeTaskReviewState(ctx, task.ID, "review-legacy-session") + + taskRepo.mu.Lock() + state := taskRepo.updatedStates[task.ID] + writes := taskRepo.stateWrites[task.ID] + taskRepo.mu.Unlock() + require.Equal(t, v1.TaskStateScheduling, state) + require.Equal(t, 1, writes) +} + // --- ListTasksWithCeilingDeferred ------------------------------------------- func TestListTasksWithCeilingDeferred_IncludesArchivedAndOrdersByID(t *testing.T) { diff --git a/apps/backend/internal/orchestrator/ceiling_seam2.go b/apps/backend/internal/orchestrator/ceiling_seam2.go index 52787c27cd..f0301439da 100644 --- a/apps/backend/internal/orchestrator/ceiling_seam2.go +++ b/apps/backend/internal/orchestrator/ceiling_seam2.go @@ -19,7 +19,7 @@ func seam2StartCreatedPayload( promptReferenceContext string, options startCreatedSessionOptions, ) map[string]interface{} { - return map[string]interface{}{ + payload := map[string]interface{}{ metaKeySessionID: sessionID, metaKeyAgentProfileID: agentProfileID, metaKeyPrompt: prompt, @@ -37,6 +37,16 @@ func seam2StartCreatedPayload( "prompt_references_prepared": options.promptReferencesPrepared, "preserve_direct_prompt": options.preserveDirectPrompt, } + if options.ceilingEntryBinding != nil { + payload[models.CeilingLaunchEntryBindingKey] = map[string]interface{}{ + "workflow_id": options.ceilingEntryBinding.WorkflowID, + "destination_step_id": options.ceilingEntryBinding.DestinationStepID, + "route_operation_id": options.ceilingEntryBinding.RouteOperationID, + "entry_identity": options.ceilingEntryBinding.EntryIdentity, + "destination_session_id": options.ceilingEntryBinding.DestinationSessionID, + } + } + return payload } // admitOrDeferSeam2 is Service.startCreatedSession's AC-4d gate: consulted diff --git a/apps/backend/internal/orchestrator/ceiling_seam3.go b/apps/backend/internal/orchestrator/ceiling_seam3.go index 6a89dbe4a3..6dbdde6dc5 100644 --- a/apps/backend/internal/orchestrator/ceiling_seam3.go +++ b/apps/backend/internal/orchestrator/ceiling_seam3.go @@ -98,6 +98,20 @@ func (s *Service) admitSeam3( func (s *Service) admitOrDeferWorkflowStepEnsure( ctx context.Context, taskID, sessionID, workflowStepID string, ) (reservation *sessionKeyedCeilingReservation, deferred bool, err error) { + return s.admitOrDeferWorkflowStepEnsureWithBinding( + ctx, taskID, sessionID, workflowStepID, ceilingEntryBindingFromContext(ctx), + ) +} + +func (s *Service) admitOrDeferWorkflowStepEnsureWithBinding( + ctx context.Context, taskID, sessionID, workflowStepID string, + binding *models.CeilingWorkflowEntryBinding, +) (reservation *sessionKeyedCeilingReservation, deferred bool, err error) { + if binding == nil && s.workflowStepGetter != nil { + if step, stepErr := s.workflowStepGetter.GetStep(ctx, workflowStepID); stepErr == nil { + binding, _ = s.workflowEntryBindingForStep(ctx, taskID, step, sessionID) + } + } decision := s.sessionCeiling.admit(ctx, admissionRequest{ taskID: taskID, sessionID: sessionID, origin: launchOriginAutomatic, seam: "startSessionForWorkflowStepPreConsult", }) @@ -112,6 +126,9 @@ func (s *Service) admitOrDeferWorkflowStepEnsure( metaKeySessionID: sessionID, metaKeyWorkflowStepID: workflowStepID, } + if binding != nil { + payload[models.CeilingLaunchEntryBindingKey] = ceilingEntryBindingValue(*binding) + } if err := s.deferCeilingRefusal(ctx, taskID, sessionID, models.CeilingLaunchWorkflowStepEnsure, payload, decision.reasonCode, decision.population, decision.populationKnown, decision.ceiling); err != nil { s.logger.Zap().Error("could not persist a ceiling deferral; the workflow step launch could not be admitted or recorded", @@ -127,11 +144,15 @@ func (s *Service) admitOrDeferWorkflowStepEnsure( // already durable in the message queue, so the record needs only the // session id and the queued message's own id (AC-47d1's already-drained // check at retry time is undecidable without it). -func (s *Service) deferSeam3QueueDrainRefusal(ctx context.Context, taskID, sessionID, queuedMessageID string, refusal *seam3Refusal) { +func (s *Service) deferSeam3QueueDrainRefusal(ctx context.Context, taskID, sessionID, queuedMessageID string, refusal *seam3Refusal, bindings ...*models.CeilingWorkflowEntryBinding) { payload := map[string]interface{}{ metaKeySessionID: sessionID, "queued_message_id": queuedMessageID, } + if len(bindings) > 0 && bindings[0] != nil { + payload[models.CeilingLaunchEntryBindingKey] = ceilingEntryBindingValue(*bindings[0]) + } + payload = s.enrichCeilingLaunchPayload(ctx, taskID, sessionID, payload) if err := s.deferCeilingRefusal(ctx, taskID, sessionID, models.CeilingLaunchQueueDrainEnsure, payload, refusal.reasonCode, refusal.population, refusal.populationKnown, refusal.ceiling); err != nil { s.logger.Zap().Error("could not persist a ceiling deferral; the workflow queue drain could not be recorded", @@ -164,7 +185,7 @@ func seam3PromptEnsurePayload( sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment, dispatchOnly bool, options promptTaskOptions, ) map[string]interface{} { - return map[string]interface{}{ + payload := map[string]interface{}{ metaKeySessionID: sessionID, metaKeyPrompt: prompt, sessionModelConfigKey: model, @@ -179,6 +200,10 @@ func seam3PromptEnsurePayload( "fallback_launch_prompt": options.fallbackLaunchPrompt, "fallback_retry_prompt": options.fallbackRetryPrompt, } + if options.ceilingEntryBinding != nil { + payload[models.CeilingLaunchEntryBindingKey] = ceilingEntryBindingValue(*options.ceilingEntryBinding) + } + return payload } // disposeSeam3PromptEnsureRefusal is promptTask's AC-47/AC-47c1/AC-47c2 @@ -197,6 +222,7 @@ func (s *Service) disposeSeam3PromptEnsureRefusal( return nil } payload := seam3PromptEnsurePayload(sessionID, prompt, model, planMode, attachments, dispatchOnly, options) + payload = s.enrichCeilingLaunchPayload(ctx, taskID, sessionID, payload) if err := s.deferCeilingRefusal(ctx, taskID, sessionID, models.CeilingLaunchPromptEnsure, payload, refusal.reasonCode, refusal.population, refusal.populationKnown, refusal.ceiling); err != nil { s.logger.Zap().Error("could not persist a ceiling deferral; the prompt could not be admitted or recorded", diff --git a/apps/backend/internal/orchestrator/ceiling_seam4.go b/apps/backend/internal/orchestrator/ceiling_seam4.go index 2609f1e938..3b331d22eb 100644 --- a/apps/backend/internal/orchestrator/ceiling_seam4.go +++ b/apps/backend/internal/orchestrator/ceiling_seam4.go @@ -10,10 +10,28 @@ import ( // seam4ResumePayload builds the AC-42 "resume" replay row from // ResumeTaskSessionWithOptions's own frame, at the point the gate is consulted. func seam4ResumePayload(sessionID string, options executor.ResumeOptions) map[string]interface{} { - return map[string]interface{}{ + return seam4ResumePayloadWithBinding(sessionID, options, nil) +} + +func seam4ResumePayloadWithBinding( + sessionID string, + options executor.ResumeOptions, + binding *models.CeilingWorkflowEntryBinding, +) map[string]interface{} { + payload := map[string]interface{}{ metaKeySessionID: sessionID, "allow_branch_replacement": options.AllowBranchReplacement, } + if binding != nil { + payload[models.CeilingLaunchEntryBindingKey] = map[string]interface{}{ + "workflow_id": binding.WorkflowID, + "destination_step_id": binding.DestinationStepID, + "route_operation_id": binding.RouteOperationID, + "entry_identity": binding.EntryIdentity, + "destination_session_id": binding.DestinationSessionID, + } + } + return payload } // admitOrDeferSeam4 is Service.ResumeTaskSessionWithOptions's gate: consulted diff --git a/apps/backend/internal/orchestrator/ceiling_seam5.go b/apps/backend/internal/orchestrator/ceiling_seam5.go index 229f05f947..97010d2c7c 100644 --- a/apps/backend/internal/orchestrator/ceiling_seam5.go +++ b/apps/backend/internal/orchestrator/ceiling_seam5.go @@ -22,6 +22,18 @@ func seam5DynamicRelaunchPayload(data watcher.AgentEventData, executionProfileID } } +func seam5DynamicRelaunchPayloadWithBinding( + data watcher.AgentEventData, + executionProfileID string, + binding *models.CeilingWorkflowEntryBinding, +) map[string]interface{} { + payload := seam5DynamicRelaunchPayload(data, executionProfileID) + if binding != nil { + payload[models.CeilingLaunchEntryBindingKey] = ceilingEntryBindingValue(*binding) + } + return payload +} + // admitOrDeferSeam5 is Service.relaunchDynamicTaskAfterFailure's gate, consulted // at entry, before the CREATED transition and before the Office/non-Office // fork. It uses handOffOrAdmit rather than admit: on the automatic path the @@ -37,6 +49,17 @@ func seam5DynamicRelaunchPayload(data watcher.AgentEventData, executionProfileID func (s *Service) admitOrDeferSeam5( ctx context.Context, taskID string, origin launchOrigin, relaunchPayload map[string]interface{}, ) (reservation *sessionKeyedCeilingReservation, deferred bool, err error) { + return s.admitOrDeferSeam5WithBinding(ctx, taskID, origin, relaunchPayload, nil) +} + +func (s *Service) admitOrDeferSeam5WithBinding( + ctx context.Context, taskID string, origin launchOrigin, relaunchPayload map[string]interface{}, + binding *models.CeilingWorkflowEntryBinding, +) (reservation *sessionKeyedCeilingReservation, deferred bool, err error) { + if binding != nil { + relaunchPayload = cloneCeilingPayload(relaunchPayload) + relaunchPayload[models.CeilingLaunchEntryBindingKey] = ceilingEntryBindingValue(*binding) + } sessionID, _ := relaunchPayload[metaKeySessionID].(string) decision := s.sessionCeiling.handOffOrAdmit(ctx, admissionRequest{ taskID: taskID, @@ -51,6 +74,7 @@ func (s *Service) admitOrDeferSeam5( populationKnown: decision.populationKnown, ceiling: decision.ceiling, }, false, nil } + relaunchPayload = s.enrichCeilingLaunchPayload(ctx, taskID, sessionID, relaunchPayload) if err := s.deferCeilingRefusal(ctx, taskID, sessionID, models.CeilingLaunchDynamicRelaunch, relaunchPayload, decision.reasonCode, decision.population, decision.populationKnown, decision.ceiling); err != nil { diff --git a/apps/backend/internal/orchestrator/ceiling_seams.go b/apps/backend/internal/orchestrator/ceiling_seams.go index 718ddba9a6..f5be6453aa 100644 --- a/apps/backend/internal/orchestrator/ceiling_seams.go +++ b/apps/backend/internal/orchestrator/ceiling_seams.go @@ -173,6 +173,9 @@ func seam1StartPayload( "session_name": opts.SpawnOrigin.SessionName, } } + if opts.ceilingEntryBinding != nil { + payload[models.CeilingLaunchEntryBindingKey] = ceilingEntryBindingValue(*opts.ceilingEntryBinding) + } return payload } @@ -201,6 +204,7 @@ func (s *Service) admitOrDeferSeam1( populationKnown: decision.populationKnown, ceiling: decision.ceiling, }, false, nil } + startPayload = s.enrichCeilingLaunchPayload(ctx, taskID, "", startPayload) if err := s.deferCeilingRefusal(ctx, taskID, "", models.CeilingLaunchStart, startPayload, decision.reasonCode, decision.population, decision.populationKnown, decision.ceiling); err != nil { diff --git a/apps/backend/internal/orchestrator/ceiling_session_keyed_reservation.go b/apps/backend/internal/orchestrator/ceiling_session_keyed_reservation.go index f108753c9e..5dd6e0dac2 100644 --- a/apps/backend/internal/orchestrator/ceiling_session_keyed_reservation.go +++ b/apps/backend/internal/orchestrator/ceiling_session_keyed_reservation.go @@ -81,6 +81,7 @@ func (s *Service) admitOrDeferSessionKeyedLaunch( populationKnown: decision.populationKnown, ceiling: decision.ceiling, }, false, nil } + payload = s.enrichCeilingLaunchPayload(ctx, taskID, sessionID, payload) if err := s.deferCeilingRefusal(ctx, taskID, sessionID, kind, payload, decision.reasonCode, decision.population, decision.populationKnown, decision.ceiling); err != nil { diff --git a/apps/backend/internal/orchestrator/dto/dto.go b/apps/backend/internal/orchestrator/dto/dto.go index a68a9a2983..7ed3272da5 100644 --- a/apps/backend/internal/orchestrator/dto/dto.go +++ b/apps/backend/internal/orchestrator/dto/dto.go @@ -46,11 +46,13 @@ type TaskSessionStatusResponse struct { AgentProfileID string `json:"agent_profile_id,omitempty"` // Runtime status - IsAgentRunning bool `json:"is_agent_running"` // Agent process is currently running - IsResumable bool `json:"is_resumable"` // Session can be resumed - NeedsResume bool `json:"needs_resume"` // Session needs resumption (page reload scenario) - NeedsWorkspaceRestore bool `json:"needs_workspace_restore"` // Session workspace can be restored (terminal state) - ResumeReason string `json:"resume_reason,omitempty"` // Why resume is needed (e.g., "agent_not_running") + IsAgentRunning bool `json:"is_agent_running"` // Agent process is currently running + IsResumable bool `json:"is_resumable"` // Session can be resumed + NeedsResume bool `json:"needs_resume"` // Session needs resumption (page reload scenario) + AutoResumeAllowed bool `json:"auto_resume_allowed"` // Passive inspection may resume this session + AutoResumeBlockedReason string `json:"auto_resume_blocked_reason,omitempty"` + NeedsWorkspaceRestore bool `json:"needs_workspace_restore"` // Session workspace can be restored (terminal state) + ResumeReason string `json:"resume_reason,omitempty"` // Why resume is needed (e.g., "agent_not_running") // ACP session info ACPSessionID string `json:"acp_session_id,omitempty"` diff --git a/apps/backend/internal/orchestrator/dynamic_launch.go b/apps/backend/internal/orchestrator/dynamic_launch.go index c254af763d..e054ee0452 100644 --- a/apps/backend/internal/orchestrator/dynamic_launch.go +++ b/apps/backend/internal/orchestrator/dynamic_launch.go @@ -403,6 +403,11 @@ func (s *Service) launchPreparedSessionWithDynamicFallbackWithContinuation( options executor.LaunchOptions, continuationInput *dynamicruntime.ContinuationInput, ) (*executor.TaskExecution, error) { + if task != nil { + if err := s.validateContextCeilingEntry(ctx, task.ID); err != nil { + return nil, err + } + } if s.profileExecutionResolver == nil { return s.launchConcretePreparedSession(ctx, task, sessionID, options) } @@ -437,6 +442,11 @@ func (s *Service) launchPreparedSessionWithDynamicFallbackWithContinuation( } else { selected.Continuation = *continuationInput } + if task != nil { + if err := s.validateContextCeilingEntry(ctx, task.ID); err != nil { + return nil, err + } + } result, err := conductor.LaunchSelected(ctx, selected) if err != nil { return nil, err @@ -456,6 +466,11 @@ func (s *Service) launchConcretePreparedSession( sessionID string, options executor.LaunchOptions, ) (*executor.TaskExecution, error) { + if task != nil { + if err := s.validateContextCeilingEntry(ctx, task.ID); err != nil { + return nil, err + } + } if options.StartAgent && (options.Prompt != "" || len(options.Attachments) > 0) { s.beginInitialPromptAttempt(sessionID, false) } @@ -1063,6 +1078,7 @@ const ( dynamicRelaunchFailed dynamicRelaunchOutcome = iota dynamicRelaunchDeferred dynamicRelaunchSucceeded + dynamicRelaunchSuperseded ) // relaunchDynamicTaskAfterFailure preserves the historical boolean API for @@ -1083,7 +1099,27 @@ func (s *Service) relaunchDynamicTaskAfterFailureOutcome( executionProfileID string, origin launchOrigin, ) (outcome dynamicRelaunchOutcome) { - seam5Res, deferredLaunch, err := s.admitOrDeferSeam5(ctx, data.TaskID, origin, seam5DynamicRelaunchPayload(data, executionProfileID)) + return s.relaunchDynamicTaskAfterFailureOutcomeWithBinding(ctx, data, executionProfileID, origin, ceilingEntryBindingFromContext(ctx)) +} + +//nolint:cyclop // Dynamic relaunch keeps admission, failure recovery, and generation fencing in one boundary. +func (s *Service) relaunchDynamicTaskAfterFailureOutcomeWithBinding( + ctx context.Context, + data watcher.AgentEventData, + executionProfileID string, + origin launchOrigin, + binding *models.CeilingWorkflowEntryBinding, +) (outcome dynamicRelaunchOutcome) { + if binding != nil { + if err := s.validateClaimedCeilingBinding(ctx, data.TaskID, binding); err != nil { + if errors.Is(err, ErrCeilingLaunchSuperseded) { + return dynamicRelaunchSuperseded + } + return dynamicRelaunchFailed + } + ctx = withCeilingEntryBinding(ctx, binding) + } + seam5Res, deferredLaunch, err := s.admitOrDeferSeam5WithBinding(ctx, data.TaskID, origin, seam5DynamicRelaunchPayloadWithBinding(data, executionProfileID, binding), binding) if err != nil { s.logger.Zap().Error("could not persist a ceiling deferral; the dynamic relaunch could not be admitted or recorded", zap.String("task_id", data.TaskID), zap.String("session_id", data.SessionID), zap.Error(err)) @@ -1121,6 +1157,14 @@ func (s *Service) relaunchDynamicTaskAfterFailureOutcome( if !ok { return dynamicRelaunchFailed } + if binding != nil { + if err := s.validateClaimedCeilingBinding(ctx, data.TaskID, binding); err != nil { + if errors.Is(err, ErrCeilingLaunchSuperseded) { + return dynamicRelaunchSuperseded + } + return dynamicRelaunchFailed + } + } return s.launchPreparedDynamicRelaunch(ctx, data, task, session, prompt, executionProfileID, seam5Res) } diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index b4c84e5cd9..9aa64f4539 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -2,6 +2,7 @@ package orchestrator import ( "context" + "errors" "fmt" "strings" "time" @@ -2425,16 +2426,18 @@ func taskArchived(task *models.Task) bool { func (s *Service) writeTaskReviewState(ctx context.Context, taskID, completedSessionID string) { // Task lookup errors fail closed so office/archived guards cannot be bypassed // by a transient repository failure. - if dbTask, err := s.repo.GetTask(ctx, taskID); err != nil { + dbTask, err := s.repo.GetTask(ctx, taskID) + switch { + case err != nil: s.logger.Warn("failed to load task before REVIEW state reconcile", zap.String("task_id", taskID), zap.Error(err)) return - } else if dbTask != nil && dbTask.IsFromOffice { + case dbTask != nil && dbTask.IsFromOffice: s.logger.Debug("skipping REVIEW transition for office task", zap.String("task_id", taskID)) return - } else if taskArchived(dbTask) { + case taskArchived(dbTask): s.logger.Debug("skipping REVIEW transition for archived task", zap.String("task_id", taskID)) return @@ -2442,6 +2445,8 @@ func (s *Service) writeTaskReviewState(ctx context.Context, taskID, completedSes s.taskRuntimeStateMu.Lock() defer s.taskRuntimeStateMu.Unlock() + ctx, releaseCeilingEntry := s.lockCeilingEntryAdmission(ctx, taskID) + defer releaseCeilingEntry() if completedSessionID != "" { if session, err := s.repo.GetTaskSession(ctx, completedSessionID); err == nil && session != nil && isWorkingSessionState(session.State) { @@ -2453,23 +2458,61 @@ func (s *Service) writeTaskReviewState(ctx context.Context, taskID, completedSes } } - if blockingSessionID, ok := s.otherWorkingSessionID(ctx, taskID, completedSessionID); !ok { + blockingSessionID, sessionsReadable := s.otherWorkingSessionID(ctx, taskID, completedSessionID) + if !sessionsReadable { return - } else if blockingSessionID != "" { + } + if blockingSessionID != "" { s.logger.Debug("skipping task REVIEW state while another session is working", zap.String("task_id", taskID), zap.String("completed_session_id", completedSessionID), zap.String("blocking_session_id", blockingSessionID)) return } + targetState := v1.TaskStateReview + allowedStates := []v1.TaskState{v1.TaskStateInProgress, v1.TaskStateScheduling} + observedDeferral, queued, queueErr := s.readValidCeilingDeferredLaunch(ctx, dbTask) + if queueErr != nil { + s.logger.Warn("skipping task REVIEW state reconcile while deferred launch ownership is uncertain", + zap.String("task_id", taskID), zap.Error(queueErr)) + return + } + // The queue row and task route can change independently of the task-state + // CAS. Re-read both at the final boundary. A replacement entry is not ours + // to reconcile from this completion callback; its own admission/sweep path + // will publish the correct projection. + latestTask, latestTaskErr := s.repo.GetTask(ctx, taskID) + if latestTaskErr != nil || latestTask == nil || latestTask.IsFromOffice || taskArchived(latestTask) { + return + } + latestDeferral, latestQueued, latestQueueErr := s.readValidCeilingDeferredLaunch(ctx, latestTask) + if latestQueueErr != nil { + s.logger.Warn("skipping task REVIEW state reconcile while final deferred launch ownership is uncertain", + zap.String("task_id", taskID), zap.Error(latestQueueErr)) + return + } + if queued && latestQueued { + equivalent, compareErr := sameCeilingDeferralIdentity(observedDeferral, latestDeferral) + if compareErr != nil || !equivalent { + return + } + } + queued = latestQueued + if queued { + // A sibling session can finish while the destination launch is waiting + // for capacity. Keep the task in Scheduling so the queued destination is + // not hidden behind a false Review state. + targetState = v1.TaskStateScheduling + allowedStates = append(allowedStates, v1.TaskStateReview) + } updated, err := s.taskRepo.UpdateTaskStateIfCurrentIn( ctx, taskID, - v1.TaskStateReview, - []v1.TaskState{v1.TaskStateInProgress, v1.TaskStateScheduling}, + targetState, + allowedStates, ) if err != nil { - s.logger.Error("failed to update task state to REVIEW", + s.logger.Error("failed to reconcile task runtime state", zap.String("task_id", taskID), zap.Error(err)) return @@ -2477,10 +2520,61 @@ func (s *Service) writeTaskReviewState(ctx context.Context, taskID, completedSes if !updated { return } - s.logger.Info("task moved to REVIEW state", + s.logger.Info("task runtime state reconciled", zap.String("task_id", taskID)) } +//nolint:cyclop,nestif // Queue reconciliation validates independent task, record, destination, and workflow-entry state. +func (s *Service) readValidCeilingDeferredLaunch( + ctx context.Context, + task *models.Task, +) (models.CeilingDeferral, bool, error) { + if task == nil || task.ArchivedAt != nil || task.State == v1.TaskStateCancelled { + return models.CeilingDeferral{}, false, nil + } + raw, _, err := s.repo.GetTaskDeferredLaunch(ctx, task.ID) + if err != nil { + return models.CeilingDeferral{}, false, err + } + if raw == nil { + return models.CeilingDeferral{}, false, nil + } + ceilingFlag, hasCeilingFlag := raw[models.CeilingDeferredKey] + if !hasCeilingFlag || ceilingFlag != true { + return models.CeilingDeferral{}, false, nil + } + deferral, err := models.ReadCeilingDeferral(raw) + if err != nil { + return models.CeilingDeferral{}, false, err + } + if sessionID := models.CeilingDeferralSessionID(task, deferral); sessionID != "" { + if !models.CeilingDeferralTargetsSession(task, deferral, sessionID) { + return models.CeilingDeferral{}, false, nil + } + session, sessionErr := s.repo.GetTaskSession(ctx, sessionID) + if sessionErr != nil { + if errors.Is(sessionErr, models.ErrTaskSessionNotFound) { + return models.CeilingDeferral{}, false, nil + } + return models.CeilingDeferral{}, false, sessionErr + } + if session == nil || session.TaskID != task.ID || isTerminalSessionState(session.State) { + return models.CeilingDeferral{}, false, nil + } + if session.State == models.TaskSessionStateStarting || session.State == models.TaskSessionStateRunning { + return models.CeilingDeferral{}, false, nil + } + } + disposition, detail, validationErr := s.validateCeilingEntry(ctx, task, deferral) + if validationErr != nil { + return models.CeilingDeferral{}, false, validationErr + } + if disposition == ceilingEntryUnavailable { + return models.CeilingDeferral{}, false, fmt.Errorf("deferred launch ownership is unavailable: %s", detail) + } + return deferral, disposition == ceilingEntryValid, nil +} + func isWorkingSessionState(state models.TaskSessionState) bool { return sessionstate.IsWorking(state) } diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index f130eeb67d..33ca756bdb 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -2883,18 +2883,18 @@ func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID strin return fmt.Errorf("load session for on_enter: %w", err) } - // entryID 0: this path (manual move / legacy on_turn_start/complete) does + // transitionID 0: this path (manual move / legacy on_turn_start/complete) does // not attach a step-entry ResultHolder before ApplyTransition, so no // entry was allocated for this step-entry. processOnEnter's engine-owned - // action cases treat entryID==0 as "not this Build round's dispatch + // action cases treat transitionID==0 as "not this Build round's dispatch // path" and skip with a log rather than executing — see // docs/specs/workflow-on-enter-action-dispatch/spec.md and the task // plan's scope note for why E2-E5 dispatch is deferred. - var entryID int64 + var transitionID int64 if len(entryIDs) > 0 { - entryID = entryIDs[0] + transitionID = entryIDs[0] } - s.processOnEnter(ctx, taskID, session, targetStep, taskDescription, entryID, sourceStep) + s.processOnEnter(ctx, taskID, session, targetStep, taskDescription, transitionID, sourceStep) return nil } @@ -4136,7 +4136,7 @@ func (s *Service) dispatchOnEnterActions(ctx context.Context, taskID string, ses } // processOnEnter processes the on_enter events for a step after transitioning -// to it. entryID is the workflow_step_entries row allocated for this +// to it. transitionID is the workflow-step transition ledger row allocated for this // step-entry by the write site that persisted the transition (0 when no // entry was allocated for this call path — see finalizeStepEnter's call // site); it is retained on this signature for callers/tests, but this @@ -4145,17 +4145,20 @@ func (s *Service) dispatchOnEnterActions(ctx context.Context, taskID string, ses // engine.DispatchStepEntry (Repository.dispatchStepEntry's call chain), // entirely outside processOnEnter. See // docs/specs/office/system-design/step-entry-dispatch-convergence.md. -func (s *Service) processOnEnter(ctx context.Context, taskID string, session *models.TaskSession, step *wfmodels.WorkflowStep, taskDescription string, entryID int64, sourceStep *wfmodels.WorkflowStep) { +func (s *Service) processOnEnter(ctx context.Context, taskID string, session *models.TaskSession, step *wfmodels.WorkflowStep, taskDescription string, transitionID int64, sourceStep *wfmodels.WorkflowStep) { // The step transition is already durable before on_enter runs. Its effects // must finish even if the request or agent-event context that triggered the // transition is cancelled. ctx = context.WithoutCancel(ctx) // One GetWorkflowMeta read shared by profile resolution and prompt build. ctx = withWorkflowMetaCache(ctx) + if !s.workflowEntryDispatchIsCurrentForSession(ctx, taskID, session.ID, step, transitionID) { + return + } // Switch session if this step requires a different agent profile. var ok bool prevSessionID := session.ID - if session, ok = s.maybySwitchSessionForProfile(ctx, taskID, session, step, sourceStep, entryID); !ok { + if session, ok = s.maybySwitchSessionForProfile(ctx, taskID, session, step, sourceStep, transitionID); !ok { return } sessionSwitched := session.ID != prevSessionID @@ -4225,7 +4228,7 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo dispatchResult := s.dispatchOnEnterActions(ctx, taskID, session, step, isPassthrough, hasPlanMode) - s.launchAfterOnEnterDispatch(ctx, taskID, session, step, taskDescription, hasPlanMode, dispatchResult.hasAutoStart, sessionSwitched) + s.launchAfterOnEnterDispatch(ctx, taskID, session, step, taskDescription, hasPlanMode, dispatchResult.hasAutoStart, sessionSwitched, transitionID) } // launchAfterOnEnterDispatch runs the auto-start decision that follows @@ -4237,9 +4240,15 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo //nolint:cyclop,funlen,gocognit // profile-switch recovery has independent terminal and retry branches. func (s *Service) launchAfterOnEnterDispatch( ctx context.Context, taskID string, session *models.TaskSession, step *wfmodels.WorkflowStep, - taskDescription string, hasPlanMode, hasAutoStart, sessionSwitched bool, + taskDescription string, hasPlanMode, hasAutoStart, sessionSwitched bool, entryIDs ...int64, ) { + if !s.workflowEntryDispatchIsCurrentForSession(ctx, taskID, session.ID, step, entryIDs...) { + return + } sessionID := session.ID + if binding, bound := s.workflowEntryBindingForStep(ctx, taskID, step, sessionID, entryIDs...); bound { + ctx = withCeilingEntryBinding(ctx, binding) + } isPassthrough := s.agentManager.IsPassthroughSession(ctx, sessionID) // One claim for this whole step entry: every branch and replacement // launch below shares it, so a successful claim's text is reused rather @@ -4354,15 +4363,16 @@ func (s *Service) launchAfterOnEnterDispatch( zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.Error(replacementErr)) return } + replacementCtx := withCeilingEntryBindingForSession(asyncCtx, replacement.ID) replacementPrompt, promptErr := s.buildWorkflowEntryPrompt( - asyncCtx, taskDescription, step, taskID, replacement.ID, isPassthrough, + replacementCtx, taskDescription, step, taskID, replacement.ID, isPassthrough, ) if promptErr != nil { - s.handleWorkflowEntryPromptError(asyncCtx, taskID, replacement, step, promptErr) + s.handleWorkflowEntryPromptError(replacementCtx, taskID, replacement, step, promptErr) return } if replacementErr = s.autoStartStepPrompt( - asyncCtx, taskID, replacement, step, replacementPrompt, planMode, true, handoffOnce, + replacementCtx, taskID, replacement, step, replacementPrompt, planMode, true, handoffOnce, ); replacementErr != nil { s.logger.Error("failed to auto-start replacement after terminalized profile switch", zap.String("task_id", taskID), zap.String("session_id", replacement.ID), zap.Error(replacementErr)) @@ -4392,15 +4402,16 @@ func (s *Service) launchAfterOnEnterDispatch( zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.Error(replacementErr)) return } + replacementCtx := withCeilingEntryBindingForSession(asyncCtx, replacement.ID) replacementPrompt, promptErr := s.buildWorkflowEntryPrompt( - asyncCtx, taskDescription, step, taskID, replacement.ID, isPassthrough, + replacementCtx, taskDescription, step, taskID, replacement.ID, isPassthrough, ) if promptErr != nil { - s.handleWorkflowEntryPromptError(asyncCtx, taskID, replacement, step, promptErr) + s.handleWorkflowEntryPromptError(replacementCtx, taskID, replacement, step, promptErr) return } if replacementErr = s.autoStartStepPrompt( - asyncCtx, taskID, replacement, step, replacementPrompt, planMode, true, handoffOnce, + replacementCtx, taskID, replacement, step, replacementPrompt, planMode, true, handoffOnce, ); replacementErr != nil { s.logger.Error("failed to auto-start replacement after terminalized dispatch", zap.String("task_id", taskID), zap.String("session_id", replacement.ID), zap.Error(replacementErr)) @@ -4465,15 +4476,16 @@ func (s *Service) replaceTerminalizedAutoStartSession( zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.Error(replacementErr)) return } + replacementCtx := withCeilingEntryBindingForSession(ctx, replacement.ID) replacementPrompt, promptErr := s.buildWorkflowEntryPrompt( - ctx, taskDescription, step, taskID, replacement.ID, isPassthrough, + replacementCtx, taskDescription, step, taskID, replacement.ID, isPassthrough, ) if promptErr != nil { - s.handleWorkflowEntryPromptError(ctx, taskID, replacement, step, promptErr) + s.handleWorkflowEntryPromptError(replacementCtx, taskID, replacement, step, promptErr) return } if replacementErr = s.autoStartStepPrompt( - ctx, taskID, replacement, step, replacementPrompt, hasPlanMode, true, handoffOnce, + replacementCtx, taskID, replacement, step, replacementPrompt, hasPlanMode, true, handoffOnce, ); replacementErr != nil { s.logger.Error("failed to auto-start replacement after reused session terminalized", zap.String("task_id", taskID), zap.String("session_id", replacement.ID), zap.Error(replacementErr)) @@ -7471,14 +7483,14 @@ func (s *Service) applyEngineTransitionWithCommitMode( // ResetAgentContext → sendStreamRequest, which blocks G_reader waiting for a response // that can only be delivered by G_reader reading from the same WebSocket — a deadlock. // The DB transition is already persisted above, so it's safe to process on_enter async. - var stepEntryID int64 + var transitionID int64 if stepEntry != nil { - stepEntryID = stepEntry.EntryID + transitionID = stepEntry.TransitionID } // The caller may still hold the source session guard, but this work runs // asynchronously after that guard is released. Clear the synchronous // ownership marker so profile-switch parking acquires its own guard. - s.launchProcessOnEnter(withoutWorkflowProfileSwitchGuard(context.WithoutCancel(ctx)), taskID, session, targetStep, taskDescription, stepEntryID, fromStep) + s.launchProcessOnEnter(withoutWorkflowProfileSwitchGuard(context.WithoutCancel(ctx)), taskID, session, targetStep, taskDescription, transitionID, fromStep) return true } @@ -7488,7 +7500,7 @@ func (s *Service) launchProcessOnEnter( session *models.TaskSession, targetStep *wfmodels.WorkflowStep, taskDescription string, - entryID int64, + transitionID int64, sourceStep *wfmodels.WorkflowStep, ) { go func() { @@ -7497,7 +7509,7 @@ func (s *Service) launchProcessOnEnter( s.onProcessOnEnterComplete() } }() - s.processOnEnter(ctx, taskID, session, targetStep, taskDescription, entryID, sourceStep) + s.processOnEnter(ctx, taskID, session, targetStep, taskDescription, transitionID, sourceStep) }() } diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_on_enter_warn_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_on_enter_warn_test.go index e909ecccd3..441a884e77 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_on_enter_warn_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_on_enter_warn_test.go @@ -182,8 +182,9 @@ func TestProcessOnEnter_LedgerOwnedKinds_DoNotWarnAndWriteNoMarker(t *testing.T) }}, } - // Allocate a real step-entry so GetStepEntryMarkerState has an entryID - // to check markers against — processOnEnter never allocates one itself. + // Allocate a real step-entry so GetStepEntryMarkerState has a marker ID to + // check. processOnEnter receives the separate transition identity, and this + // direct test has no transition callback to validate. task, err := repo.GetTask(ctx, "task-ledger-owned") if err != nil { t.Fatalf("load task: %v", err) @@ -206,7 +207,7 @@ func TestProcessOnEnter_LedgerOwnedKinds_DoNotWarnAndWriteNoMarker(t *testing.T) t.Fatalf("expected a real entryID to be allocated") } - svc.processOnEnter(ctx, "task-ledger-owned", session, step, "", holder.EntryID, nil) + svc.processOnEnter(ctx, "task-ledger-owned", session, step, "", 0, nil) var warnings []observer.LoggedEntry for _, e := range logs.All() { diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index 095858bbae..6e0a47fb72 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -170,8 +170,9 @@ func (h *Handlers) wsLaunchSession(ctx context.Context, msg *ws.Message) (*ws.Me } type wsEnsureSessionRequest struct { - TaskID string `json:"task_id"` - AutoStart *bool `json:"auto_start,omitempty"` + TaskID string `json:"task_id"` + AutoStart *bool `json:"auto_start,omitempty"` + ActivationSource orchestrator.LaunchActivationSource `json:"activation_source,omitempty"` } // wsEnsureSession ensures a task has a session, honoring the auto_start override. @@ -184,7 +185,9 @@ func (h *Handlers) wsEnsureSession(ctx context.Context, msg *ws.Message) (*ws.Me return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) } - resp, err := h.service.EnsureSession(ctx, req.TaskID, orchestrator.EnsureSessionOptions{AutoStart: req.AutoStart}) + resp, err := h.service.EnsureSession(ctx, req.TaskID, orchestrator.EnsureSessionOptions{ + AutoStart: req.AutoStart, ActivationSource: req.ActivationSource, + }) if err != nil { h.logger.Error("failed to ensure session", zap.String("task_id", req.TaskID), diff --git a/apps/backend/internal/orchestrator/queue_send_now.go b/apps/backend/internal/orchestrator/queue_send_now.go index 3ad0a4f95d..248f1fc177 100644 --- a/apps/backend/internal/orchestrator/queue_send_now.go +++ b/apps/backend/internal/orchestrator/queue_send_now.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "sync" "time" @@ -286,6 +287,20 @@ func (s *Service) dispatchSendNowSelection( } return len(entries), nil } + if sessionState == models.TaskSessionStateCreated { + // A queued destination has no running provider for promptTask to reach. + // Send Now is an explicit user action, so its worker starts the prepared + // session through the manual admission path. A failed start returns the + // claim to the queue, preserving the pending message for retry. + dispatched, err := s.claimAndDispatchSendNow(ctx, identity, sessionID, scope, entries) + if err != nil { + return 0, err + } + if !dispatched { + return 0, ErrSendNowQueueChanged + } + return len(entries), nil + } if !errors.Is(promptabilityErr, ErrAgentPromptInProgress) { return 0, promptabilityErr } @@ -558,6 +573,7 @@ func (s *Service) claimSendNowExecution(sessionID, dispatchID string) error { return nil } +//nolint:nestif // Send Now keeps transcript, ceiling, and created-session ownership in one dispatch boundary. func (s *Service) promptSendNowClaim(ctx context.Context, claim *messagequeue.SendNowClaim) (bool, error) { sessionID := claim.Dispatch.SessionID deliveryAttempted := false @@ -569,8 +585,134 @@ func (s *Service) promptSendNowClaim(ctx context.Context, claim *messagequeue.Se if err := s.prepareSendNowTranscript(ctx, claim, attachments, durablePlanComments); err != nil { return false, err } + ceilingClaim, ceilingQueued, err := s.claimCeilingDeferredLaunch( + ctx, claim.Dispatch.TaskID, sessionID, ceilingClaimOwnerSendNow, + ) + if err != nil { + return false, err + } + if ceilingQueued && ceilingClaim == nil { + return false, ErrCeilingLaunchClaimed + } + var currentTask *models.Task + if ceilingClaim != nil { + defer ceilingClaim.releaseIfHeld(ctx) + var taskErr error + currentTask, taskErr = s.repo.GetTask(ctx, claim.Dispatch.TaskID) + if taskErr != nil { + return false, fmt.Errorf("reload task before send now launch: %w", taskErr) + } + originalDeferral := ceilingClaim.deferral + boundDeferral, bindErr := s.enrichCeilingDeferralBinding(ctx, currentTask, originalDeferral) + if bindErr != nil { + return false, fmt.Errorf("bind deferred workflow entry before send now launch: %w", bindErr) + } + matches, compareErr := ceilingDeferralsEquivalentForAdmission(boundDeferral, originalDeferral) + if compareErr != nil { + return false, fmt.Errorf("compare deferred workflow entry before send now launch: %w", compareErr) + } + if !matches { + return false, fmt.Errorf("%w: deferred launch changed before Send Now", ErrCeilingLaunchSuperseded) + } + ceilingClaim.deferral = boundDeferral + if disposition, detail, validationErr := s.validateCeilingEntry(ctx, currentTask, ceilingClaim.deferral); validationErr != nil { + return false, validationErr + } else if disposition != ceilingEntryValid { + if detail == "" { + detail = "deferred workflow entry ownership is unavailable" + } + return false, fmt.Errorf("%w: %s", ErrCeilingLaunchSuperseded, detail) + } + } + var deferredLaunch *models.CeilingDeferral + if ceilingClaim != nil { + deferredLaunch = &ceilingClaim.deferral + } - _, err := s.promptTask(ctx, claim.Dispatch.TaskID, sessionID, promptContent, claim.Dispatch.Model, + if session, err := s.repo.GetTaskSession(ctx, sessionID); err != nil { + return false, fmt.Errorf("load session before send now launch: %w", err) + } else if session != nil && session.State == models.TaskSessionStateCreated { + startOptions := startCreatedSessionOptions{} + createdPrompt := promptContent + createdSkipMessageRecord := false + createdPlanMode := claim.Dispatch.PlanMode + createdAttachments := attachments + createdReferences := []v1.EntityReference(nil) + createdPromptReferenceContext := "" + if ceilingClaim != nil { + binding, bindingPresent, bindingErr := models.ReadCeilingWorkflowEntryBinding(ceilingClaim.deferral.Payload) + if bindingErr != nil { + return false, bindingErr + } + if bindingPresent { + startOptions.ceilingEntryBinding = &binding + // Mark this as a durable ceiling replay that Send Now has + // explicitly claimed. startCreatedSession must keep the strict + // committed-route validation; it must not use the prepared + // callback compatibility path for this exact record. + ctx = withCeilingEntryKind(ctx, ceilingClaim.deferral.Kind) + } + if deferredLaunch != nil { + input := sendNowCreatedLaunchInput( + currentTask, + deferredLaunch, + promptContent, + attachments, + claim.Dispatch.PlanMode, + ) + createdPrompt = input.prompt + createdSkipMessageRecord = input.skipMessageRecord + createdPlanMode = input.planMode + createdAttachments = input.attachments + createdReferences = input.references + createdPromptReferenceContext = input.promptReferenceContext + startOptions.skipTaskDescriptionFallback = input.skipTaskDescriptionFallback + startOptions.promptAlreadyComposed = input.promptAlreadyComposed + startOptions.retryPrompt = input.retryPrompt + startOptions.preserveDirectPrompt = input.preserveDirectPrompt + if input.recordQueueMessage { + if err := s.recordQueuedUserMessage(ctx, &claim.Dispatch, attachments); err != nil { + return false, fmt.Errorf("record Send Now continuation before created-session launch: %w", err) + } + markSendNowSourcesRecorded(claim) + } + } + } + execution, startErr := s.startCreatedSession( + ctx, + claim.Dispatch.TaskID, + sessionID, + "", + createdPrompt, + createdSkipMessageRecord || durablePlanComments, + createdPlanMode, + false, + createdAttachments, + createdReferences, + createdPromptReferenceContext, + startOptions, + ) + if startErr != nil { + var acceptedDispatch *acceptedPromptDispatchError + if ceilingClaim != nil && errors.As(startErr, &acceptedDispatch) { + // The created-session path can return a handled post-dispatch + // error after agentctl accepted the prompt. The exact ceiling + // record is no longer replayable once that boundary was crossed. + ceilingClaim.settle(ctx) + } + return false, startErr + } + if execution == nil { + return false, errors.New("send-now launch did not dispatch a created session") + } + if ceilingClaim != nil { + ceilingClaim.settle(ctx) + } + s.processSendNowTurnStart(ctx, claim) + return true, nil + } + + _, err = s.promptTask(ctx, claim.Dispatch.TaskID, sessionID, promptContent, claim.Dispatch.Model, claim.Dispatch.PlanMode, attachments, false, launchOriginManual, promptTaskOptions{ claimEntryID: claim.Dispatch.ID, afterClaim: s.sendNowAfterClaim(ctx, claim, attachments, durablePlanComments), @@ -580,9 +722,97 @@ func (s *Service) promptSendNowClaim(ctx context.Context, claim *messagequeue.Se return s.markSendNowClaimAcceptedWithRetry(ctx, claim) }, }) + var acceptedDispatch *acceptedPromptDispatchError + if err == nil || deliveryAttempted || errors.As(err, &acceptedDispatch) { + if ceilingClaim != nil { + ceilingClaim.settle(ctx) + } + } return deliveryAttempted, err } +type sendNowCreatedLaunchDetails struct { + prompt string + skipMessageRecord bool + planMode bool + attachments []v1.MessageAttachment + references []v1.EntityReference + promptReferenceContext string + skipTaskDescriptionFallback bool + promptAlreadyComposed bool + retryPrompt string + preserveDirectPrompt bool + recordQueueMessage bool +} + +// sendNowCreatedLaunchInput folds the pending Continue into the exact +// workflow launch that was waiting for capacity. The workflow prompt may +// already be recorded and composed, so it must not be discarded or composed a +// second time. The queue message is recorded separately when the original +// launch already owns its transcript row. +func sendNowCreatedLaunchInput( + task *models.Task, + deferral *models.CeilingDeferral, + continuation string, + queueAttachments []v1.MessageAttachment, + queuePlanMode bool, +) sendNowCreatedLaunchDetails { + input := sendNowCreatedLaunchDetails{ + prompt: continuation, + planMode: queuePlanMode, + attachments: append([]v1.MessageAttachment(nil), queueAttachments...), + references: nil, + } + if deferral == nil { + return input + } + + payload := deferral.Payload + input.skipMessageRecord = boolField(payload, "skip_message_record") + input.skipTaskDescriptionFallback = boolField(payload, "skip_task_description_fallback") + input.promptAlreadyComposed = boolField(payload, "prompt_already_composed") + input.preserveDirectPrompt = boolField(payload, "preserve_direct_prompt") + input.planMode = input.planMode || boolField(payload, metaKeyPlanMode) + input.promptReferenceContext = stringField(payload, "prompt_reference_context") + + originalPrompt := stringField(payload, metaKeyPrompt) + input.retryPrompt = stringField(payload, "retry_prompt") + if input.promptAlreadyComposed && originalPrompt == "" { + originalPrompt = input.retryPrompt + } + if originalPrompt == "" && !input.skipTaskDescriptionFallback && task != nil { + originalPrompt = task.Description + } + input.prompt = appendSendNowPrompt(originalPrompt, continuation) + if input.retryPrompt == "" { + input.retryPrompt = originalPrompt + } + input.retryPrompt = appendSendNowPrompt(input.retryPrompt, continuation) + + var originalAttachments []v1.MessageAttachment + decodeCeilingPayloadField(payload[metaKeyAttachments], &originalAttachments) + input.attachments = append(originalAttachments, input.attachments...) + decodeCeilingPayloadField(payload["references"], &input.references) + + // A workflow auto-start records its own prompt before admission and sets + // skip_message_record. Record the queued Continue before launching so a + // failed launch can restore the queue without losing its transcript receipt. + input.recordQueueMessage = input.skipMessageRecord && + (strings.TrimSpace(continuation) != "" || len(queueAttachments) > 0) + return input +} + +func appendSendNowPrompt(original, continuation string) string { + switch { + case strings.TrimSpace(original) == "": + return continuation + case strings.TrimSpace(continuation) == "": + return original + default: + return original + "\n\n" + continuation + } +} + func (s *Service) prepareSendNowTranscript( ctx context.Context, claim *messagequeue.SendNowClaim, diff --git a/apps/backend/internal/orchestrator/queue_send_now_test.go b/apps/backend/internal/orchestrator/queue_send_now_test.go index 12a9e5b850..465291d872 100644 --- a/apps/backend/internal/orchestrator/queue_send_now_test.go +++ b/apps/backend/internal/orchestrator/queue_send_now_test.go @@ -359,6 +359,181 @@ func TestPromptSendNowClaimSkipsOnTurnStartWhenAlreadyProcessed(t *testing.T) { } } +func TestPromptSendNowClaimStartsCreatedSessionManually(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSessionWithStep(t, repo, "task-created", "session-created", "step-created") + seedExecutorRunning(t, repo, "session-created", "task-created", "exec-created") + + stepGetter := newMockStepGetter() + stepGetter.steps["step-created"] = &wfmodels.WorkflowStep{ + ID: "step-created", WorkflowID: "wf1", Name: "Created step", Position: 0, + } + stepGetter.workflowAgentProfileID = "profile-created" + taskRepo := newMockTaskRepo() + seedMockTaskState(taskRepo, "task-created", v1.TaskStateInProgress) + agentMgr := &mockAgentManager{repoForExecutionLookup: repo} + svc := createTestServiceWithScheduler(repo, stepGetter, taskRepo, agentMgr) + messages := &mockMessageCreator{} + svc.messageCreator = messages + + identity, err := svc.messageQueue.ResolveSessionIdentity(ctx, "task-created", "session-created") + if err != nil { + t.Fatalf("resolve session identity: %v", err) + } + claim := &messagequeue.SendNowClaim{ + Identity: identity, + Dispatch: messagequeue.QueuedMessage{ + ID: "queued-created", + TaskID: "task-created", + SessionID: "session-created", + Content: "manual capacity override", + }, + } + + deliveryAttempted, err := svc.promptSendNowClaim(ctx, claim) + if err != nil { + t.Fatalf("prompt Send Now claim: %v", err) + } + if !deliveryAttempted { + t.Fatal("created-session Send Now did not report delivery") + } + if len(messages.userMessages) != 1 { + t.Fatalf("created-session Send Now recorded %d user messages, want 1", len(messages.userMessages)) + } + + session, err := repo.GetTaskSession(ctx, "session-created") + if err != nil { + t.Fatalf("reload created session: %v", err) + } + if session.State == models.TaskSessionStateCreated { + t.Fatal("created-session Send Now left the session in CREATED") + } + agentMgr.mu.Lock() + descriptionCalls := len(agentMgr.setExecutionDescriptionCalls) + agentMgr.mu.Unlock() + if descriptionCalls != 1 { + t.Fatalf("created-session Send Now made %d launch description calls, want 1", descriptionCalls) + } +} + +func TestSendQueuedNowConsumesCeilingLaunchAndPreservesWorkflowPrompt(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSessionWithStep(t, repo, "queued-created", "queued-session", "queued-step") + seedExecutorRunning(t, repo, "queued-session", "queued-created", "prepared-exec") + + stepGetter := newMockStepGetter() + stepGetter.steps["queued-step"] = &wfmodels.WorkflowStep{ + ID: "queued-step", WorkflowID: "wf1", Name: "Queued workflow step", Position: 0, + } + stepGetter.workflowAgentProfileID = "profile-queued" + taskRepo := newMockTaskRepo() + seedMockTaskState(taskRepo, "queued-created", v1.TaskStateScheduling) + agentMgr := &mockAgentManager{repoForExecutionLookup: repo} + svc := createTestServiceWithScheduler(repo, stepGetter, taskRepo, agentMgr) + svc.messageCreator = &mockMessageCreator{} + t.Cleanup(svc.stopSendNowWorkers) + + queuedAt := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + deferral := models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + metaKeySessionID: "queued-session", + metaKeyAgentProfileID: "profile-queued", + metaKeyPrompt: "workflow prompt", + "skip_message_record": true, + }, + Origin: string(launchOriginAutomatic), + ReasonCode: ceilingReasonRefused, + QueuedAt: queuedAt, + Ceiling: 5, + Population: 6, + PopulationKnown: true, + } + if err := repo.SetTaskMetadataKey(ctx, "queued-created", models.MetaKeyDeferredLaunch, models.CeilingRecordKeys(deferral)); err != nil { + t.Fatalf("persist ceiling deferral: %v", err) + } + queued, err := svc.messageQueue.QueueMessage( + ctx, "queued-session", "queued-created", "pending Continue", "", messagequeue.QueuedByUser, false, nil, + ) + if err != nil { + t.Fatalf("queue Continue: %v", err) + } + + sent, err := svc.SendQueuedNow(ctx, "queued-session", QueueSendNowScopeEntry, queued.ID) + if err != nil { + t.Fatalf("SendQueuedNow: %v", err) + } + if sent != 1 { + t.Fatalf("SendQueuedNow sent %d entries, want 1", sent) + } + + deadline := time.Now().Add(3 * time.Second) + launched := false + for time.Now().Before(deadline) { + agentMgr.mu.Lock() + descriptionCalls := len(agentMgr.setExecutionDescriptionCalls) + descriptions := make([]string, 0, descriptionCalls) + for _, call := range agentMgr.setExecutionDescriptionCalls { + descriptions = append(descriptions, call.Prompt) + } + agentMgr.mu.Unlock() + if descriptionCalls == 1 { + if !strings.Contains(descriptions[0], "workflow prompt") || !strings.Contains(descriptions[0], "pending Continue") { + t.Fatalf("created-session launch prompt = %q, want workflow prompt and Continue", descriptions[0]) + } + launched = true + break + } + time.Sleep(10 * time.Millisecond) + } + if !launched { + session, sessionErr := repo.GetTaskSession(ctx, "queued-session") + status := svc.messageQueue.GetStatus(ctx, "queued-session") + agentMgr.mu.Lock() + capturedPrompts := append([]string(nil), agentMgr.capturedPrompts...) + descriptionCalls := append([]promptCall(nil), agentMgr.setExecutionDescriptionCalls...) + agentMgr.mu.Unlock() + t.Fatalf("SendQueuedNow did not accept the created-session launch before the timeout: record=%#v session=%#v session_err=%v queue=%#v prompts=%#v descriptions=%#v", deferredLaunchOf(t, svc, "queued-created"), session, sessionErr, status, capturedPrompts, descriptionCalls) + } + + // The created-session launch is owned by the Send Now worker. The agent + // manager callback above happens before that worker returns and settles the + // exact ceiling record, so wait for the durable settlement boundary before + // asserting the replay is gone. + settleDeadline := time.Now().Add(3 * time.Second) + for time.Now().Before(settleDeadline) { + record := deferredLaunchOf(t, svc, "queued-created") + if record == nil || !models.HasCeilingDeferredIntent(&models.Task{Metadata: map[string]interface{}{ + models.MetaKeyDeferredLaunch: record, + }}) { + break + } + time.Sleep(10 * time.Millisecond) + } + record := deferredLaunchOf(t, svc, "queued-created") + if record != nil && models.HasCeilingDeferredIntent(&models.Task{Metadata: map[string]interface{}{ + models.MetaKeyDeferredLaunch: record, + }}) { + t.Fatalf("SendQueuedNow left the original ceiling launch replayable: %#v", record) + } + agentMgr.mu.Lock() + beforeSweeps := len(agentMgr.setExecutionDescriptionCalls) + agentMgr.mu.Unlock() + + // Completion and every later sweep must observe an empty ceiling half. In + // particular, a replay must not send the old workflow prompt a second time. + svc.drainDeferredCeilingLaunches(ctx) + svc.drainDeferredCeilingLaunches(ctx) + agentMgr.mu.Lock() + afterSweeps := len(agentMgr.setExecutionDescriptionCalls) + agentMgr.mu.Unlock() + if afterSweeps != beforeSweeps { + t.Fatalf("subsequent ceiling sweeps dispatched %d duplicate launches", afterSweeps-beforeSweeps) + } +} + func TestPromptSendNowClaimRejectsPlanCommentWhenTranscriptPersistenceFails(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index bcbf891f54..945a479464 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -701,6 +701,13 @@ type Service struct { autoStartOnCreateMu sync.Mutex autoStartOnCreateInFlight map[string]struct{} + // ceilingEntryAdmissionLocks serialize the durable workflow-entry binding, + // ceiling queue, and task-state reconciliation for one task. The lock is + // deliberately task-scoped so unrelated queued launches can progress in + // parallel while an old entry cannot race a successor route. + ceilingEntryAdmissionLocksMu sync.Mutex + ceilingEntryAdmissionLocks map[string]*ceilingEntryAdmissionLock + // Message creator for saving agent responses messageCreator MessageCreator diff --git a/apps/backend/internal/orchestrator/session_ceiling.go b/apps/backend/internal/orchestrator/session_ceiling.go index 01a1e21b0d..c3ba7cd796 100644 --- a/apps/backend/internal/orchestrator/session_ceiling.go +++ b/apps/backend/internal/orchestrator/session_ceiling.go @@ -94,6 +94,16 @@ type admissionDecision struct { handedOff bool } +// SessionCeilingObservation is a point-in-time view of the admission +// controller. It is deliberately separate from a launch deferral so callers +// can refresh displayed capacity without changing queue ownership or time. +type SessionCeilingObservation struct { + InUse int + Limit int + ObservedAt time.Time + Known bool +} + // sessionCeilingController is the single admission controller. Every mutation of // the reservation set happens under its one mutex, together with the population // read it is compared against. @@ -132,6 +142,40 @@ func (c *sessionCeilingController) population(ctx context.Context) (int, error) return c.populationLocked(counted), nil } +func (c *sessionCeilingController) observation(ctx context.Context) (SessionCeilingObservation, error) { + if c == nil { + return SessionCeilingObservation{}, nil + } + c.mu.Lock() + defer c.mu.Unlock() + counted, err := c.countedRowsLocked(ctx) + observedAt := time.Now().UTC() + if c.now != nil { + observedAt = c.now().UTC() + } + observation := SessionCeilingObservation{ + Limit: c.ceiling, + ObservedAt: observedAt, + Known: err == nil, + } + if err != nil { + return observation, err + } + observation.InUse = c.populationLocked(counted) + return observation, nil +} + +// CurrentSessionCeilingObservation exposes the controller's current bounded +// reading to composition-layer status projections. A population read failure +// returns the ceiling and a Known=false observation so queue ownership can +// still be displayed without claiming a count. +func (s *Service) CurrentSessionCeilingObservation(ctx context.Context) (SessionCeilingObservation, error) { + if s == nil || s.sessionCeiling == nil { + return SessionCeilingObservation{}, nil + } + return s.sessionCeiling.observation(ctx) +} + // countedRowsLocked reads the persisted half of the population. It is derived at // decision time rather than kept as a running tally, so a restart, a panic or a // missed release cannot leak capacity permanently. diff --git a/apps/backend/internal/orchestrator/session_ensure.go b/apps/backend/internal/orchestrator/session_ensure.go index 461c632250..f2840c76f2 100644 --- a/apps/backend/internal/orchestrator/session_ensure.go +++ b/apps/backend/internal/orchestrator/session_ensure.go @@ -12,14 +12,16 @@ import ( // EnsureSessionResponse describes the outcome of EnsureSession. type EnsureSessionResponse struct { - Success bool `json:"success"` - TaskID string `json:"task_id"` - SessionID string `json:"session_id,omitempty"` - State string `json:"state"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - Source string `json:"source"` // existing_primary | existing_newest | created_prepare | created_start | skipped_terminal_pr - NewlyCreated bool `json:"newly_created"` // true when a new session was created by this call - WorkspacePath string `json:"workspace_path,omitempty"` // effective workspace path (for quick-chat sessions without worktrees) + Success bool `json:"success"` + TaskID string `json:"task_id"` + SessionID string `json:"session_id,omitempty"` + State string `json:"state"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + Source string `json:"source"` // existing_primary | existing_newest | created_prepare | created_start | skipped_terminal_pr + NewlyCreated bool `json:"newly_created"` // true when a new session was created by this call + WorkspacePath string `json:"workspace_path,omitempty"` // effective workspace path (for quick-chat sessions without worktrees) + ActivationDisposition string `json:"activation_disposition,omitempty"` + ActivationReason string `json:"activation_reason,omitempty"` } // EnsureSessionOptions holds optional parameters for EnsureSession. @@ -35,6 +37,9 @@ type EnsureSessionOptions struct { // upgraded into an agent start. Absent (nil) keeps the step-derived // decision. AutoStart *bool + // ActivationSource distinguishes passive task opening from an explicit + // launch action. A passive open never resumes an existing session. + ActivationSource LaunchActivationSource } // ensureLocks serializes EnsureSession calls per task id so concurrent callers @@ -79,9 +84,22 @@ func (s *Service) EnsureSession(ctx context.Context, taskID string, opts ...Ensu if len(opts) > 0 { o = opts[0] } + if err := validateLaunchActivationSource(o.ActivationSource); err != nil { + return nil, err + } + + // A passive task open must follow the session already selected by a + // deferred launch. Returning the ordinary primary session here would make + // the browser inspect the parked predecessor while the queued destination + // remained hidden, and a later open could attempt a second launch. + if o.ActivationSource == LaunchActivationSourceSessionOpen { + if queued, handled := s.queuedEnsureResponse(ctx, taskID); handled { + return queued, nil + } + } if existing := s.findExistingSession(ctx, taskID); existing != nil { - if o.EnsureExecution { + if o.EnsureExecution && o.ActivationSource != LaunchActivationSourceSessionOpen { s.tryEnsureExecution(ctx, existing.SessionID, seam3CallShapeViewing, launchOriginManual, "") } return existing, nil @@ -106,13 +124,14 @@ func (s *Service) EnsureSession(ctx context.Context, taskID string, opts ...Ensu } launchResp, err := s.LaunchSession(ctx, &LaunchSessionRequest{ - TaskID: taskID, - Intent: intent, - AgentProfileID: agentProfileID, - WorkflowStepID: task.WorkflowStepID, - LaunchWorkspace: true, - AutoStart: intent == IntentStart, - NoAgentLaunch: o.AutoStart != nil && !*o.AutoStart, + TaskID: taskID, + Intent: intent, + AgentProfileID: agentProfileID, + WorkflowStepID: task.WorkflowStepID, + LaunchWorkspace: true, + AutoStart: intent == IntentStart, + NoAgentLaunch: o.AutoStart != nil && !*o.AutoStart, + ActivationSource: o.ActivationSource, }) if err != nil { return nil, err @@ -125,26 +144,123 @@ func (s *Service) EnsureSession(ctx context.Context, taskID string, opts ...Ensu // found to have a terminal pull request. Keep ensure idempotent and let // the task-owned launch error card render without creating a session. return &EnsureSessionResponse{ - Success: true, - TaskID: taskID, - State: launchResp.State, - AgentProfileID: agentProfileID, - Source: "skipped_terminal_pr", - NewlyCreated: false, + Success: true, + TaskID: taskID, + State: launchResp.State, + AgentProfileID: agentProfileID, + Source: "skipped_terminal_pr", + NewlyCreated: false, + ActivationDisposition: launchResp.ActivationDisposition, + ActivationReason: launchResp.ActivationReason, }, nil } return &EnsureSessionResponse{ - Success: true, - TaskID: taskID, - SessionID: launchResp.SessionID, - State: launchResp.State, - AgentProfileID: agentProfileID, - Source: source, - NewlyCreated: true, + Success: true, + TaskID: taskID, + SessionID: launchResp.SessionID, + State: launchResp.State, + AgentProfileID: agentProfileID, + Source: source, + NewlyCreated: true, + ActivationDisposition: launchResp.ActivationDisposition, + ActivationReason: launchResp.ActivationReason, }, nil } +// queuedEnsureResponse returns the exact destination owned by a durable +// automatic launch deferral. handled is true whenever the task has a ceiling +// record, including malformed or orphaned records: passive inspection must +// remain read-only and must not fall back to creating or resuming another +// session when ownership cannot be established. +func (s *Service) queuedEnsureResponse(ctx context.Context, taskID string) (*EnsureSessionResponse, bool) { + task, err := s.repo.GetTask(ctx, taskID) + if err != nil || task == nil { + return queuedEnsureOwnershipUnavailableResponse(taskID, "", ""), true + } + if !models.HasCeilingDeferredIntent(task) { + return nil, false + } + record, _ := task.Metadata[models.MetaKeyDeferredLaunch].(map[string]interface{}) + deferral, err := models.ReadCeilingDeferral(record) + if err != nil { + return queuedEnsureOwnershipUnavailableResponse(taskID, "", ""), true + } + return s.queuedEnsureResponseForDeferral(ctx, taskID, task, deferral), true +} + +func (s *Service) queuedEnsureResponseForDeferral( + ctx context.Context, + taskID string, + task *models.Task, + deferral models.CeilingDeferral, +) *EnsureSessionResponse { + sessionID := models.CeilingDeferralSessionID(task, deferral) + agentProfileID := stringField(deferral.Payload, metaKeyAgentProfileID) + if queuedEnsureOwnershipUnavailable(task, deferral, sessionID) { + return queuedEnsureOwnershipUnavailableResponse(taskID, sessionID, agentProfileID) + } + if sessionID == "" { + return queuedEnsureCapacityResponse(taskID, agentProfileID) + } + + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil || session == nil || session.TaskID != taskID { + return queuedEnsureOwnershipUnavailableResponse(taskID, sessionID, agentProfileID) + } + + response := s.existingResponse(ctx, taskID, session, "existing_queued") + response.ActivationDisposition = activationDispositionQueued + response.ActivationReason = "session_capacity" + return response +} + +func queuedEnsureOwnershipUnavailable( + task *models.Task, + deferral models.CeilingDeferral, + sessionID string, +) bool { + _, bindingPresent, bindingErr := models.ReadCeilingWorkflowEntryBinding(deferral.Payload) + workflowOrigin := bindingPresent || stringField(deferral.Payload, metaKeyWorkflowStepID) != "" || + int64Field(deferral.Payload, "workflow_entry_id") > 0 + if deferral.Kind == models.CeilingLaunchStart { + if _, routePresent := models.LoadWorkflowSessionRoute(task.Metadata); routePresent { + workflowOrigin = true + } + } + return bindingErr != nil || (workflowOrigin && (sessionID == "" || + (bindingPresent && !models.CeilingDeferralTargetsSession(task, deferral, sessionID)))) +} + +func queuedEnsureOwnershipUnavailableResponse( + taskID, sessionID, agentProfileID string, +) *EnsureSessionResponse { + return &EnsureSessionResponse{ + Success: true, + TaskID: taskID, + SessionID: sessionID, + AgentProfileID: agentProfileID, + State: string(models.TaskSessionStateCreated), + Source: "queued", + NewlyCreated: false, + ActivationDisposition: activationDispositionSuppressed, + ActivationReason: autoResumeBlockedOwnershipUnavailable, + } +} + +func queuedEnsureCapacityResponse(taskID, agentProfileID string) *EnsureSessionResponse { + return &EnsureSessionResponse{ + Success: true, + TaskID: taskID, + State: string(models.TaskSessionStateCreated), + AgentProfileID: agentProfileID, + Source: "queued", + NewlyCreated: false, + ActivationDisposition: activationDispositionQueued, + ActivationReason: "session_capacity", + } +} + // findExistingSession returns the task's existing session for advanced-mode // resume. For office tasks, it picks the (task, agent) row matching the // authenticated agent context (or the task's assignee when the viewer is the @@ -267,31 +383,44 @@ func (s *Service) existingResponse(ctx context.Context, taskID string, sess *mod func (s *Service) tryEnsureExecution( ctx context.Context, sessionID string, callShape seam3CallShape, origin launchOrigin, queuedMessageID string, ) { + _ = s.tryEnsureExecutionWithBinding( + ctx, sessionID, callShape, origin, queuedMessageID, ceilingEntryBindingFromContext(ctx), + ) +} + +func (s *Service) tryEnsureExecutionWithBinding( + ctx context.Context, sessionID string, callShape seam3CallShape, origin launchOrigin, queuedMessageID string, + binding *models.CeilingWorkflowEntryBinding, +) error { + if binding != nil { + ctx = withCeilingEntryBinding(ctx, binding) + } session, err := s.repo.GetTaskSession(ctx, sessionID) if err != nil || session == nil { - return + return err } - err = s.ensureSessionRunning(ctx, sessionID, session, origin) + err = s.ensureSessionRunningWithBinding(ctx, sessionID, session, origin, binding) if err == nil { - return + return nil } if refusal, ok := isSeam3Refusal(err); ok { switch callShape { case seam3CallShapeViewing: - return + return nil case seam3CallShapeQueueDrain: - s.deferSeam3QueueDrainRefusal(ctx, session.TaskID, sessionID, queuedMessageID, refusal) - return + s.deferSeam3QueueDrainRefusal(ctx, session.TaskID, sessionID, queuedMessageID, refusal, binding) + return err default: s.logger.Zap().Warn("tryEnsureExecution saw an unrecognized seam-3 call shape; defaulting to deferring", zap.String("session_id", sessionID), zap.String("call_shape", string(callShape))) - s.deferSeam3QueueDrainRefusal(ctx, session.TaskID, sessionID, queuedMessageID, refusal) - return + s.deferSeam3QueueDrainRefusal(ctx, session.TaskID, sessionID, queuedMessageID, refusal, binding) + return err } } s.logger.Debug("ensure execution for existing session (non-fatal)", zap.String("session_id", sessionID), zap.Error(err)) + return err } // resolveTaskAgentProfile applies the 5-step resolution chain on the backend: diff --git a/apps/backend/internal/orchestrator/session_ensure_test.go b/apps/backend/internal/orchestrator/session_ensure_test.go index 5a4360c937..238f2f376f 100644 --- a/apps/backend/internal/orchestrator/session_ensure_test.go +++ b/apps/backend/internal/orchestrator/session_ensure_test.go @@ -90,6 +90,70 @@ func TestEnsureSession_ReturnsExistingNewest_NoPrimary(t *testing.T) { } } +func TestEnsureSession_PassiveOpenReturnsExactQueuedDestination(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + + seedTaskAndSession(t, repo, "task1", "session-astra", models.TaskSessionStateWaitingForInput) + if err := repo.SetSessionPrimary(ctx, "session-astra"); err != nil { + t.Fatalf("set parked session primary: %v", err) + } + now := time.Now().UTC() + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-luna", TaskID: "task1", State: models.TaskSessionStateCreated, + AgentProfileID: "profile-luna", StartedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("create queued destination: %v", err) + } + queuedAt := now.Add(-time.Minute) + record := models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + metaKeySessionID: "session-luna", + metaKeyAgentProfileID: "profile-luna", + metaKeyWorkflowStepID: "step-implement", + }, + Origin: string(launchOriginAutomatic), + QueuedAt: queuedAt, + Population: 5, + PopulationKnown: true, + Ceiling: 5, + }) + if err := repo.SetTaskMetadataKey(ctx, "task1", models.MetaKeyDeferredLaunch, record); err != nil { + t.Fatalf("set queued launch: %v", err) + } + + response, err := svc.EnsureSession(ctx, "task1", EnsureSessionOptions{ + ActivationSource: LaunchActivationSourceSessionOpen, + }) + if err != nil { + t.Fatalf("EnsureSession: %v", err) + } + if response.SessionID != "session-luna" { + t.Fatalf("passive ensure session = %q, want exact queued destination", response.SessionID) + } + if response.Source != "existing_queued" || response.ActivationDisposition != "queued" || + response.ActivationReason != "session_capacity" { + t.Fatalf("passive queued response = %+v", response) + } + + sessions, err := repo.ListTaskSessions(ctx, "task1") + if err != nil { + t.Fatalf("list sessions: %v", err) + } + if len(sessions) != 2 { + t.Fatalf("passive ensure created or removed a session: got %d rows", len(sessions)) + } + queuedTask, err := repo.GetTask(ctx, "task1") + if err != nil { + t.Fatalf("reload task: %v", err) + } + if !models.HasCeilingDeferredIntent(queuedTask) { + t.Fatal("passive ensure cleared the queued launch") + } +} + func TestFindOfficeSessionForResumeUsesCanonicalOfficeProjection(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/orchestrator/session_launch.go b/apps/backend/internal/orchestrator/session_launch.go index b3783a9549..4485dec492 100644 --- a/apps/backend/internal/orchestrator/session_launch.go +++ b/apps/backend/internal/orchestrator/session_launch.go @@ -34,6 +34,16 @@ const ( IntentRestoreWorkspace SessionIntent = "restore_workspace" // Restore workspace access for terminal-state session ) +type LaunchActivationSource string + +const ( + LaunchActivationSourceUserAction LaunchActivationSource = "user_action" + LaunchActivationSourceSessionOpen LaunchActivationSource = "session_open" + + activationDispositionSuppressed = "suppressed" + activationDispositionQueued = "queued" +) + // LaunchSessionRequest is the unified request for session.launch. type LaunchSessionRequest struct { TaskID string `json:"task_id"` @@ -89,6 +99,9 @@ type LaunchSessionRequest struct { // ordinary launch, ensure, and startup recovery paths must keep completed // sessions terminal. AllowCompletedSessionResume bool `json:"-"` + // ActivationSource distinguishes passive task opening from an explicit + // launch action. An omitted value preserves the existing behavior. + ActivationSource LaunchActivationSource `json:"activation_source,omitempty"` } // SpawnOrigin describes the agent session that spawned a new sibling session. @@ -102,14 +115,16 @@ type SpawnOrigin struct { // LaunchSessionResponse is the unified response for session.launch. type LaunchSessionResponse struct { - Success bool `json:"success"` - TaskID string `json:"task_id"` - SessionID string `json:"session_id,omitempty"` - AgentExecutionID string `json:"agent_execution_id,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - State string `json:"state"` - WorktreePath *string `json:"worktree_path,omitempty"` - WorktreeBranch *string `json:"worktree_branch,omitempty"` + Success bool `json:"success"` + TaskID string `json:"task_id"` + SessionID string `json:"session_id,omitempty"` + AgentExecutionID string `json:"agent_execution_id,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + State string `json:"state"` + WorktreePath *string `json:"worktree_path,omitempty"` + WorktreeBranch *string `json:"worktree_branch,omitempty"` + ActivationDisposition string `json:"activation_disposition,omitempty"` + ActivationReason string `json:"activation_reason,omitempty"` } // ResolveIntent infers the session intent from request fields when Intent is empty. @@ -161,7 +176,16 @@ func IsBenignLaunchTeardownErr(err error) bool { // LaunchSession is the unified entry point for all session operations. func (s *Service) LaunchSession(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) { + if req == nil { + return nil, errors.New("launch request is required") + } req.Prompt = strings.TrimSpace(req.Prompt) + if err := validateLaunchActivationSource(req.ActivationSource); err != nil { + return nil, err + } + if req.ActivationSource == LaunchActivationSourceSessionOpen && req.Prompt != "" { + return nil, errors.New("session_open activation cannot include a prompt") + } intent := ResolveIntent(req) // Every intent funnels through here. SessionID is empty when creating, so // that case is carried by the task check alone. @@ -180,6 +204,9 @@ func (s *Service) LaunchSession(ctx context.Context, req *LaunchSessionRequest) if err := s.authorizeTaskSessionPair(ctx, req.TaskID, req.SessionID); err != nil { return nil, err } + if response := s.passiveLaunchResponse(ctx, req, intent); response != nil { + return response, nil + } if err := s.claimLaunchAttachments(ctx, req); err != nil { return nil, fmt.Errorf("claim launch attachments: %w", err) } @@ -201,6 +228,78 @@ func (s *Service) LaunchSession(ctx context.Context, req *LaunchSessionRequest) } } +func validateLaunchActivationSource(source LaunchActivationSource) error { + if source != "" && source != LaunchActivationSourceUserAction && source != LaunchActivationSourceSessionOpen { + return fmt.Errorf("unknown launch activation source %q", source) + } + return nil +} + +// passiveLaunchResponse is the server-side guard for browser inspection. The +// status endpoint is the normal fast path, but a launch request must repeat +// the ownership check because the status can be stale by the time the browser +// sends it. It returns a successful no-execution disposition so callers do not +// fall back to a fresh session or restore attempt. +func (s *Service) passiveLaunchResponse( + ctx context.Context, req *LaunchSessionRequest, intent SessionIntent, +) *LaunchSessionResponse { + if req == nil || req.ActivationSource != LaunchActivationSourceSessionOpen || s.repo == nil { + return nil + } + switch intent { + case IntentPrepare, IntentRestoreWorkspace, IntentWorkflowStep: + return nil + } + if req.SessionID == "" { + return s.deferredLaunchResponseIfPresent(ctx, req, "") + } + + task, taskErr := s.repo.GetTask(ctx, req.TaskID) + session, sessionErr := s.repo.GetTaskSession(ctx, req.SessionID) + if taskErr != nil || sessionErr != nil || task == nil || session == nil || session.TaskID != req.TaskID { + return &LaunchSessionResponse{ + Success: true, + TaskID: req.TaskID, + SessionID: req.SessionID, + State: sessionStateOrEmpty(session), + AgentProfileID: sessionProfileOrEmpty(session), + ActivationDisposition: activationDispositionSuppressed, + ActivationReason: "ownership_unavailable", + } + } + allowed, reason := s.autoResumeEligibility(ctx, task, session) + if allowed { + return nil + } + disposition := activationDispositionSuppressed + if reason == autoResumeBlockedLaunchQueued { + disposition = activationDispositionQueued + } + return &LaunchSessionResponse{ + Success: true, + TaskID: req.TaskID, + SessionID: session.ID, + State: string(session.State), + AgentProfileID: session.AgentProfileID, + ActivationDisposition: disposition, + ActivationReason: reason, + } +} + +func sessionStateOrEmpty(session *models.TaskSession) string { + if session == nil { + return "" + } + return string(session.State) +} + +func sessionProfileOrEmpty(session *models.TaskSession) string { + if session == nil { + return "" + } + return session.AgentProfileID +} + func (s *Service) claimLaunchAttachments(ctx context.Context, req *LaunchSessionRequest) error { hasDescriptor := false for _, attachment := range req.Attachments { @@ -264,7 +363,7 @@ func (s *Service) launchPrepare(ctx context.Context, req *LaunchSessionRequest) // imminent prompt-bearing start) get the eager launch. See launchPrepare for // why AutoStart and DeferredStart each suppress it. func (s *Service) shouldUpgradePassthroughPrepare(ctx context.Context, req *LaunchSessionRequest) bool { - if req.NoAgentLaunch { + if req.NoAgentLaunch || req.ActivationSource == LaunchActivationSourceSessionOpen { return false } return !req.AutoStart && !req.DeferredStart && s.isPassthroughProfile(ctx, req.AgentProfileID) @@ -302,7 +401,8 @@ func (s *Service) blocksAutoStartLaunch(ctx context.Context, req *LaunchSessionR // is downgraded to a prepare (workspace-only, no agent) to prevent unwanted // auto-starts from the frontend's useAutoStartSession hook. func (s *Service) launchStart(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) { - if req.AutoStart && s.blocksAutoStartLaunch(ctx, req) { + autoStart := req.AutoStart || req.ActivationSource == LaunchActivationSourceSessionOpen + if autoStart && s.blocksAutoStartLaunch(ctx, req) { req.LaunchWorkspace = true return s.launchPrepare(ctx, req) } @@ -310,22 +410,19 @@ func (s *Service) launchStart(ctx context.Context, req *LaunchSessionRequest) (* execution, err := s.startTask( ctx, req.TaskID, req.AgentProfileID, req.ExecutorID, req.ExecutorProfileID, req.Priority, req.Prompt, - req.WorkflowStepID, req.PlanMode, req.AutoStart, req.Attachments, + req.WorkflowStepID, req.PlanMode, autoStart, req.Attachments, startTaskOptions{ProfileExplicit: req.ProfileExplicit, SpawnOrigin: req.SpawnOrigin}, ) if errors.Is(err, ErrCeilingLaunchDeferred) { - // A ceiling-deferred launch (only reachable when req.AutoStart is - // true — a manual launch is always admitted, never deferred) is not - // this caller's failure to report: the sweep already persisted a - // replay record and owns retrying it. Fall through to the same - // no-op response the nil-execution case below returns, so the - // task-owned card note (not an RPC error) is the recovery surface. - execution, err = nil, nil + return s.deferredLaunchResponse(ctx, req, "") } if err != nil { return nil, err } if execution == nil { + if response := s.deferredLaunchResponseIfPresent(ctx, req, ""); response != nil { + return response, nil + } // The automatic terminal-PR gate intentionally skips session creation. // Return a successful no-op response so session.ensure and WS callers do // not dereference a nil execution while the task-owned error card remains @@ -370,29 +467,208 @@ func (s *Service) shouldBlockAutoStart(ctx context.Context, req *LaunchSessionRe // launchStartCreated starts agent execution on an existing CREATED session. func (s *Service) launchStartCreated(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) { + autoStart := req.AutoStart || req.ActivationSource == LaunchActivationSourceSessionOpen + parkingStamp := s.captureWorkflowParkingStamp(ctx, req.SessionID) execution, err := s.StartCreatedSession( ctx, req.TaskID, req.SessionID, req.AgentProfileID, - req.Prompt, req.SkipMessageRecord, req.PlanMode, req.AutoStart, req.Attachments, nil, + req.Prompt, req.SkipMessageRecord, req.PlanMode, autoStart, req.Attachments, nil, ) if err != nil { return nil, err } + if execution == nil { + if response := s.deferredLaunchResponseIfPresent(ctx, req, req.SessionID); response != nil { + return response, nil + } + } + if execution != nil { + s.clearWorkflowParkingForSession(ctx, req.SessionID, parkingStamp) + } return executionToLaunchResponse(req.TaskID, execution), nil } // 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{ AllowBranchReplacement: req.AllowBranchReplacement, AllowCompletedSessionResume: req.AllowCompletedSessionResume, - Origin: string(originFromAutoStart(req.AutoStart)), + Origin: string(launchOriginForActivation(req)), }) if err != nil { return nil, err } + if execution == nil { + if response := s.deferredLaunchResponseIfPresent(ctx, req, req.SessionID); response != nil { + return response, nil + } + } + if execution != nil { + s.clearWorkflowParkingForSession(ctx, req.SessionID, parkingStamp) + } return executionToLaunchResponse(req.TaskID, execution), nil } +func launchOriginForActivation(req *LaunchSessionRequest) launchOrigin { + if req != nil && req.ActivationSource == LaunchActivationSourceSessionOpen { + return launchOriginAutomatic + } + return originFromAutoStart(req != nil && req.AutoStart) +} + +func (s *Service) deferredLaunchResponse( + ctx context.Context, + req *LaunchSessionRequest, + sessionID string, +) (*LaunchSessionResponse, error) { + response := s.deferredLaunchResponseIfPresent(ctx, req, sessionID) + if response == nil { + return nil, ErrCeilingLaunchDeferred + } + return response, nil +} + +func (s *Service) deferredLaunchResponseIfPresent( + ctx context.Context, + req *LaunchSessionRequest, + sessionID string, +) *LaunchSessionResponse { + if req == nil { + return nil + } + task, err := s.repo.GetTask(ctx, req.TaskID) + if err != nil || task == nil { + return deferredLaunchSuppressedForInspection(req, sessionID, autoResumeBlockedOwnershipUnavailable) + } + if !models.HasCeilingDeferredIntent(task) { + return nil + } + record, _ := task.Metadata[models.MetaKeyDeferredLaunch].(map[string]interface{}) + deferral, err := models.ReadCeilingDeferral(record) + if err != nil { + return deferredLaunchOwnershipUnavailableResponse(req, sessionID) + } + return s.deferredLaunchResponseForDeferral(req, task, deferral, sessionID) +} + +func (s *Service) deferredLaunchResponseForDeferral( + req *LaunchSessionRequest, + task *models.Task, + deferral models.CeilingDeferral, + sessionID string, +) *LaunchSessionResponse { + // Seam-1 workflow starts do not have a session id in their payload. Resolve + // that destination from the task-owned route so opening the task reports the + // exact queued session instead of falling back to the request's empty id. + deferredSessionID := models.CeilingDeferralSessionID(task, deferral) + if sessionID != "" && deferredSessionID != "" && sessionID != deferredSessionID { + return deferredLaunchSuppressedForInspection(req, sessionID, autoResumeBlockedOwnershipUnavailable) + } + _, bindingPresent, bindingErr := models.ReadCeilingWorkflowEntryBinding(deferral.Payload) + workflowOrigin := deferredLaunchIsWorkflowOrigin(task, deferral, bindingPresent) + if req.ActivationSource == LaunchActivationSourceSessionOpen && + (bindingErr != nil || (workflowOrigin && (deferredSessionID == "" || + (bindingPresent && !models.CeilingDeferralTargetsSession(task, deferral, deferredSessionID))))) { + return deferredLaunchSuppressedForInspection(req, sessionID, autoResumeBlockedOwnershipUnavailable) + } + if sessionID == "" { + sessionID = deferredSessionID + if sessionID == "" { + sessionID = req.SessionID + } + } + agentProfileID := req.AgentProfileID + if agentProfileID == "" { + agentProfileID = stringField(deferral.Payload, metaKeyAgentProfileID) + } + return &LaunchSessionResponse{ + Success: true, + TaskID: req.TaskID, + SessionID: sessionID, + AgentProfileID: agentProfileID, + State: string(models.TaskSessionStateCreated), + ActivationDisposition: activationDispositionQueued, + ActivationReason: "session_capacity", + } +} + +func deferredLaunchSuppressedForInspection( + req *LaunchSessionRequest, + sessionID, reason string, +) *LaunchSessionResponse { + if req == nil || req.ActivationSource != LaunchActivationSourceSessionOpen { + return nil + } + return &LaunchSessionResponse{ + Success: true, + TaskID: req.TaskID, + SessionID: sessionID, + ActivationDisposition: activationDispositionSuppressed, + ActivationReason: reason, + } +} + +func deferredLaunchOwnershipUnavailableResponse( + req *LaunchSessionRequest, + sessionID string, +) *LaunchSessionResponse { + return &LaunchSessionResponse{ + Success: true, + TaskID: req.TaskID, + SessionID: sessionID, + ActivationDisposition: activationDispositionSuppressed, + ActivationReason: "ownership_unavailable", + } +} + +func deferredLaunchIsWorkflowOrigin( + task *models.Task, + deferral models.CeilingDeferral, + bindingPresent bool, +) bool { + workflowOrigin := bindingPresent || stringField(deferral.Payload, metaKeyWorkflowStepID) != "" || + int64Field(deferral.Payload, "workflow_entry_id") > 0 + if deferral.Kind != models.CeilingLaunchStart { + return workflowOrigin + } + _, routePresent := models.LoadWorkflowSessionRoute(task.Metadata) + return workflowOrigin || routePresent +} + +func (s *Service) captureWorkflowParkingStamp(ctx context.Context, sessionID string) string { + if sessionID == "" { + return "" + } + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil || session == nil { + return "" + } + parking, ok := models.LoadWorkflowParking(session.Metadata) + if !ok { + return "" + } + return parking.Stamp +} + +// clearWorkflowParkingForSession removes only the stamped parking marker that +// was authorized before the launch. A later park writes a new stamp and is +// therefore preserved when an older launch completes. +func (s *Service) clearWorkflowParkingForSession(ctx context.Context, sessionID, authorizedStamp string) { + if sessionID == "" || strings.TrimSpace(authorizedStamp) == "" { + return + } + remover, ok := s.repo.(workflowProfileSwitchStopIntentRemover) + if !ok { + return + } + if _, err := remover.RemoveSessionMetadataKeyIfStamp( + ctx, sessionID, models.SessionMetaKeyWorkflowParking, authorizedStamp, + ); err != nil { + s.logger.Warn("failed to clear workflow parking marker after launch", + zap.String("session_id", sessionID), zap.Error(err)) + } +} + // launchWorkflowStep starts a session with workflow step prompt configuration. func (s *Service) launchWorkflowStep(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) { err := s.StartSessionForWorkflowStep(ctx, req.TaskID, req.SessionID, req.WorkflowStepID) diff --git a/apps/backend/internal/orchestrator/session_launch_test.go b/apps/backend/internal/orchestrator/session_launch_test.go index c0fd5e4f57..b6b013684d 100644 --- a/apps/backend/internal/orchestrator/session_launch_test.go +++ b/apps/backend/internal/orchestrator/session_launch_test.go @@ -174,6 +174,230 @@ func TestResolveIntent(t *testing.T) { } } +func TestValidateLaunchActivationSource(t *testing.T) { + for _, source := range []LaunchActivationSource{ + "", + LaunchActivationSourceUserAction, + LaunchActivationSourceSessionOpen, + } { + if err := validateLaunchActivationSource(source); err != nil { + t.Errorf("validateLaunchActivationSource(%q) = %v", source, err) + } + } + if err := validateLaunchActivationSource("background_recovery"); err == nil { + t.Fatal("unknown launch activation source was accepted") + } +} + +func TestPassiveLaunchResponseSuppressesParkedSession(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("load session: %v", err) + } + session.IsPrimary = false + session.Metadata[models.SessionMetaKeyWorkflowParking] = models.WorkflowParking{ + Stamp: "parking-1", + ParkedAt: time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC), + SourceSessionID: "session1", + } + parking := session.Metadata[models.SessionMetaKeyWorkflowParking] + delete(session.Metadata, models.SessionMetaKeyWorkflowParking) + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("update session: %v", err) + } + if err := repo.SetSessionMetadataKey(ctx, "session1", models.SessionMetaKeyWorkflowParking, parking); err != nil { + t.Fatalf("set parking metadata: %v", err) + } + + service := &Service{repo: repo} + response, err := service.LaunchSession(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) + } + updated, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("reload session: %v", err) + } + if _, ok := models.LoadWorkflowParking(updated.Metadata); !ok { + t.Fatal("passive inspection cleared the parking marker") + } +} + +func TestPassiveLaunchResponseReportsQueuedDestination(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCreated) + queuedAt := time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC) + record := models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{metaKeySessionID: "session1", metaKeyAgentProfileID: "profile-luna"}, + Origin: string(launchOriginAutomatic), + QueuedAt: queuedAt, + }) + if err := repo.SetTaskMetadataKey(ctx, "task1", models.MetaKeyDeferredLaunch, record); err != nil { + t.Fatalf("set deferred launch: %v", err) + } + + service := &Service{repo: repo} + response, err := service.LaunchSession(ctx, &LaunchSessionRequest{ + TaskID: "task1", + SessionID: "session1", + Intent: IntentResume, + ActivationSource: LaunchActivationSourceSessionOpen, + }) + if err != nil { + t.Fatalf("LaunchSession: %v", err) + } + if response == nil || response.ActivationDisposition != "queued" { + t.Fatalf("passive queued launch response = %#v, want queued disposition", response) + } + if response.SessionID != "session1" || response.ActivationReason != autoResumeBlockedLaunchQueued { + t.Fatalf("passive queued response = %#v", response) + } +} + +func TestPassiveLaunchResponseResolvesSessionlessQueuedDestinationFromRoute(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCreated) + queuedAt := time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC) + route := models.WorkflowSessionRoute{ + OperationID: "workflow-route-1", + DestinationStepID: "implement", + EntryIdentity: "entry:00000000000000000001", + TargetKind: "new_session", + DestinationID: "session1", + Phase: "committed", + } + if err := repo.SetTaskMetadataKey(ctx, "task1", models.MetaKeyWorkflowSessionRoute, route); err != nil { + t.Fatalf("set workflow route: %v", err) + } + record := models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStart, + Payload: map[string]interface{}{ + metaKeyWorkflowStepID: "implement", + }, + Origin: string(launchOriginAutomatic), + QueuedAt: queuedAt, + }) + if err := repo.SetTaskMetadataKey(ctx, "task1", models.MetaKeyDeferredLaunch, record); err != nil { + t.Fatalf("set deferred launch: %v", err) + } + + service := &Service{repo: repo} + response, err := service.LaunchSession(ctx, &LaunchSessionRequest{ + TaskID: "task1", + Intent: IntentStart, + ActivationSource: LaunchActivationSourceSessionOpen, + }) + if err != nil { + t.Fatalf("LaunchSession: %v", err) + } + if response == nil || response.ActivationDisposition != "queued" || response.SessionID != "session1" { + t.Fatalf("sessionless passive queued response = %#v, want queued session1", response) + } +} + +func TestClearWorkflowParkingForSessionPreservesNewerMarkerAndStopIntent(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", string(models.TaskSessionStateWaitingForInput)) + old := models.WorkflowParking{ + Stamp: "parking-old", + ParkedAt: time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC), + SourceSessionID: "session1", + } + if err := repo.SetSessionMetadataKey(ctx, "session1", models.SessionMetaKeyWorkflowParking, old); err != nil { + t.Fatalf("set old parking marker: %v", err) + } + if err := repo.SetSessionMetadataKey(ctx, "session1", models.SessionMetaKeyWorkflowProfileSwitchStopIntent, + models.WorkflowProfileSwitchStopIntent{ExecutionID: "execution-old", Stamp: old.Stamp}); err != nil { + t.Fatalf("set stop intent: %v", err) + } + + service := &Service{repo: repo, logger: testLogger()} + service.clearWorkflowParkingForSession(ctx, "session1", old.Stamp) + updated, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("reload after exact parking clear: %v", err) + } + if _, ok := models.LoadWorkflowParking(updated.Metadata); ok { + t.Fatal("matching launch did not clear the current parking marker") + } + if _, ok := workflowProfileSwitchStopIntentFromMetadata(updated.Metadata); !ok { + t.Fatal("clearing parking removed the execution stop-intent tombstone") + } + + newMarker := old + newMarker.Stamp = "parking-new" + newMarker.RouteOperationID = "route-new" + if err := repo.SetSessionMetadataKey(ctx, "session1", models.SessionMetaKeyWorkflowParking, newMarker); err != nil { + t.Fatalf("set newer parking marker: %v", err) + } + service.clearWorkflowParkingForSession(ctx, "session1", old.Stamp) + updated, err = repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("reload after stale parking clear: %v", err) + } + parking, ok := models.LoadWorkflowParking(updated.Metadata) + if !ok || parking.Stamp != newMarker.Stamp { + t.Fatalf("stale launch clear replaced newer parking marker: %#v", updated.Metadata) + } +} + +func TestPassiveLaunchResponseBlocksAmbiguousLegacyParking(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("load session: %v", err) + } + session.IsPrimary = false + session.Metadata[models.SessionMetaKeyWorkflowProfileSwitchStopIntent] = models.WorkflowProfileSwitchStopIntent{ + ExecutionID: "execution-1", + Stamp: "legacy-parking-1", + Consumed: true, + } + legacyIntent := session.Metadata[models.SessionMetaKeyWorkflowProfileSwitchStopIntent] + delete(session.Metadata, models.SessionMetaKeyWorkflowProfileSwitchStopIntent) + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("update session: %v", err) + } + if err := repo.SetSessionMetadataKey(ctx, "session1", models.SessionMetaKeyWorkflowProfileSwitchStopIntent, legacyIntent); err != nil { + t.Fatalf("set legacy parking metadata: %v", err) + } + + service := &Service{repo: repo} + response, err := service.LaunchSession(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) + } +} + func TestLaunchSession_RejectsAttachmentClaimBeforeStart(t *testing.T) { wantErr := errors.New("attachment is not available") claimer := &rejectingLaunchAttachmentClaimer{wantErr: wantErr} diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index b5168bf1e1..b5419f7924 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -615,6 +615,9 @@ type startCreatedSessionOptions struct { // server-owned expansion snapshot, including an empty snapshot. promptReferencesPrepared bool preserveDirectPrompt bool + // ceilingEntryBinding is carried by a replay and checked at both the + // admission boundary and immediately before runtime dispatch. + ceilingEntryBinding *models.CeilingWorkflowEntryBinding } //nolint:cyclop,funlen,gocognit // Existing complexity inherited from session-lifecycle handling. @@ -629,6 +632,9 @@ func (s *Service) startCreatedSession( ) (*executor.TaskExecution, error) { releaseLifecycleLock := s.acquireSessionLifecycleLock(sessionID) defer releaseLifecycleLock() + if options.ceilingEntryBinding == nil { + options.ceilingEntryBinding = ceilingEntryBindingFromContext(ctx) + } // One GetWorkflowMeta read shared by profile resolution and prompt build. ctx = withWorkflowMetaCache(ctx) @@ -652,6 +658,9 @@ func (s *Service) startCreatedSession( if session.State != models.TaskSessionStateCreated && session.State != models.TaskSessionStateWaitingForInput { return nil, fmt.Errorf("session is not in CREATED or WAITING_FOR_INPUT state (current: %s)", session.State) } + if err := s.validateClaimedCeilingBinding(ctx, taskID, options.ceilingEntryBinding); err != nil { + return nil, err + } // Office-owned sessions must be started by the scheduler. Reject before // resolving profiles or persisting any caller-derived session metadata. @@ -663,8 +672,9 @@ func (s *Service) startCreatedSession( return nil, errOfficeTaskStartRequiresScheduler } - seam2Res, deferred, err := s.admitOrDeferSeam2(ctx, taskID, sessionID, originFromAutoStart(autoStart), - seam2StartCreatedPayload(sessionID, agentProfileID, prompt, skipMessageRecord, planMode, autoStart, attachments, references, promptReferenceContext, options)) + startPayload := seam2StartCreatedPayload(sessionID, agentProfileID, prompt, skipMessageRecord, planMode, autoStart, attachments, references, promptReferenceContext, options) + startPayload = s.enrichCeilingLaunchPayload(ctx, taskID, sessionID, startPayload) + seam2Res, deferred, err := s.admitOrDeferSeam2(ctx, taskID, sessionID, originFromAutoStart(autoStart), startPayload) if err != nil { return nil, err } @@ -865,6 +875,9 @@ func (s *Service) startCreatedSession( isOfficeTask, configMode, titleOwner, includeCanvasGuidance, references, promptReferenceContext, ) } + if err := s.validateClaimedCeilingBinding(ctx, taskID, options.ceilingEntryBinding); err != nil { + return nil, err + } executorID := session.ExecutorID @@ -881,6 +894,12 @@ func (s *Service) startCreatedSession( mcpMode = executor.McpModeOffice } initialTurnID, initialTurnCreated := s.startTurnForSessionWithOwnership(ctx, sessionID) + if err := s.validateClaimedCeilingBinding(ctx, taskID, options.ceilingEntryBinding); err != nil { + if initialTurnCreated { + s.completeTurnIfCurrent(ctx, sessionID, initialTurnID) + } + return nil, err + } execution, err := s.launchPreparedSessionWithDynamicFallback(ctx, task, sessionID, executor.LaunchOptions{AgentProfileID: effectiveProfileID, ExecutorID: executorID, Prompt: effectivePrompt, StartAgent: true, McpMode: mcpMode, Attachments: attachments, TurnID: initialTurnID}) if err != nil { // The executor persists LaunchAgent failures. Cover earlier prepared-session @@ -1169,6 +1188,10 @@ type startTaskOptions struct { // process to start now: an Office scheduler launch only chose a provider. // Zero value means "derive from autoStart". Origin launchOrigin + // ceilingEntryBinding is set only by a replay that owns a persisted + // workflow-entry record. The start path rechecks it immediately before + // runtime admission so a stale route cannot dispatch the old payload. + ceilingEntryBinding *models.CeilingWorkflowEntryBinding } // StartTaskWithRoute launches a stable Office identity through a complete @@ -1232,6 +1255,7 @@ func (s *Service) prepareExplicitWorkflowStartRoute( route := &models.WorkflowSessionRoute{ OperationID: workflowSessionRouteID(taskID, candidateStep.ID, s.workflowEntryIdentity(ctx, taskID, entryIDs...), candidateStep.SessionTarget, startPolicy), DestinationStepID: candidateStep.ID, + EntryIdentity: s.workflowEntryIdentity(ctx, taskID, entryIDs...), TargetKind: string(candidateStep.SessionTarget.Kind), TargetStepID: candidateStep.SessionTarget.StepID, AgentProfileID: profileID, @@ -1277,7 +1301,7 @@ func (s *Service) promoteSelectedExplicitWorkflowSession( return session.ID, true, nil } -//nolint:cyclop,funlen,gocognit // launch path threads many orthogonal concerns (workflow-step / agent-profile / office-task / config-mode / route / system-prompt wrapping); splitting it would require shared mutable state across helpers +//nolint:cyclop,funlen,gocognit,nestif // launch path threads many orthogonal concerns (workflow-step / agent-profile / office-task / config-mode / route / system-prompt wrapping); splitting it would require shared mutable state across helpers func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID string, executorID string, executorProfileID string, priority string, prompt string, workflowStepID string, planMode, autoStart bool, attachments []v1.MessageAttachment, opts startTaskOptions) (*executor.TaskExecution, error) { ctx = executor.WithTaskRunnerProfileExplicit(ctx, strings.TrimSpace(executorProfileID) != "") // One GetWorkflowMeta read shared by profile resolution and prompt build. @@ -1308,6 +1332,30 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s if origin == "" { origin = originFromAutoStart(autoStart) } + claimedCeilingBinding := opts.ceilingEntryBinding != nil + // Bind workflow-origin launches before seam 1 records a sessionless + // deferral. The destination session does not exist yet, so this uses the + // task's current route when available and otherwise the immutable step-entry + // identity. A later replay must compare this binding before it allocates or + // dispatches a destination session. + if opts.ceilingEntryBinding == nil && (workflowStepID != "" || opts.WorkflowEntryID > 0) { + if bindingTask, bindingErr := s.repo.GetTask(ctx, taskID); bindingErr == nil && bindingTask != nil { + bindingPayload := map[string]interface{}{ + metaKeyWorkflowStepID: workflowStepID, + } + if opts.WorkflowEntryID > 0 { + bindingPayload["workflow_entry_id"] = opts.WorkflowEntryID + } + if binding, bound := s.deriveCeilingEntryBinding(ctx, bindingTask, bindingPayload, ""); bound { + opts.ceilingEntryBinding = &binding + } + } + } + if claimedCeilingBinding { + if err := s.validateClaimedCeilingBinding(ctx, taskID, opts.ceilingEntryBinding); err != nil { + return nil, err + } + } seam1Res, deferred, err := s.admitOrDeferSeam1(ctx, taskID, origin, seam1StartPayload(agentProfileID, executorID, executorProfileID, priority, prompt, workflowStepID, planMode, autoStart, attachments, opts)) if err != nil { @@ -1633,7 +1681,20 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s if isOfficeTask { mcpMode = executor.McpModeOffice } + if claimedCeilingBinding { + if err := s.validateClaimedCeilingBinding(ctx, taskID, opts.ceilingEntryBinding); err != nil { + return nil, err + } + } initialTurnID, initialTurnCreated := s.startTurnForSessionWithOwnership(ctx, sessionID) + if claimedCeilingBinding { + if err := s.validateClaimedCeilingBinding(ctx, taskID, opts.ceilingEntryBinding); err != nil { + if initialTurnCreated { + s.completeTurnIfCurrent(ctx, sessionID, initialTurnID) + } + return nil, err + } + } // Cache the raw prompt so a transient-provider-error (529) retry can // re-drive this first turn — initial launches bypass PromptTask. @@ -2820,6 +2881,7 @@ func (s *Service) resumeTaskSessionWithContinuation( options executor.ResumeOptions, continuation func(context.Context, *resumeAttempt, *executor.TaskExecution) error, ) (*executor.TaskExecution, error) { + entryBinding := ceilingEntryBindingFromContext(ctx) s.logger.Debug("resuming task session", zap.String("task_id", taskID), zap.String("session_id", sessionID)) @@ -2833,6 +2895,9 @@ func (s *Service) resumeTaskSessionWithContinuation( if session.TaskID != taskID { return nil, fmt.Errorf("task session does not belong to task") } + if err := s.validateClaimedCeilingBinding(ctx, taskID, entryBinding); err != nil { + return nil, err + } allowCompletedResume := options.AllowCompletedSessionResume && session.State == models.TaskSessionStateCompleted // The completed-session permission is valid only for the exact completed @@ -2912,7 +2977,7 @@ func (s *Service) resumeTaskSessionWithContinuation( if isOfficeTask { return nil, decorateResumeFailure(errOfficeTaskResumeRequiresScheduler) } - seam4Res, deferred, err := s.admitOrDeferSeam4(ctx, taskID, sessionID, launchOrigin(options.Origin), seam4ResumePayload(sessionID, options)) + seam4Res, deferred, err := s.admitOrDeferSeam4(ctx, taskID, sessionID, launchOrigin(options.Origin), seam4ResumePayloadWithBinding(sessionID, options, entryBinding)) if err != nil { return nil, err } @@ -2948,6 +3013,10 @@ func (s *Service) resumeTaskSessionWithContinuation( zap.Error(err)) } } + if err := s.validateClaimedCeilingBinding(resumeCtx, taskID, entryBinding); err != nil { + s.cleanupCancelledResumeAttempt(attempt) + return nil, err + } execution, err := s.executor.ResumeSessionWithOptions(resumeCtx, session, true, options) if execution != nil { attempt.setExecutionID(execution.AgentExecutionID) @@ -3157,6 +3226,7 @@ func (s *Service) waitForStartingSessionPromptable(ctx context.Context, taskID, // step's prompt_prefix, prompt_suffix, and plan_mode settings combined with the task description. func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessionID, workflowStepID string) error { ctx = withWorkflowMetaCache(ctx) + entryBinding := ceilingEntryBindingFromContext(ctx) s.logger.Debug("starting session for workflow step", zap.String("task_id", taskID), zap.String("session_id", sessionID), @@ -3196,12 +3266,21 @@ func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessi if err != nil { return fmt.Errorf("failed to get task: %w", err) } + derivedEntryBinding := false + replayWorkflowStepBinding := entryBinding != nil && + ceilingEntryKindFromContext(ctx) == models.CeilingLaunchWorkflowStepEnsure + if entryBinding == nil { + if derivedBinding, derived := s.workflowEntryBindingForStep(ctx, taskID, step, sessionID); derived { + entryBinding = derivedBinding + ctx = withCeilingEntryBinding(ctx, entryBinding) + derivedEntryBinding = true + } + } if session.ReviewStatus == models.ReviewStatusPending { return fmt.Errorf("session is pending approval - use Approve button to proceed or send a message to request changes") } - - preConsultRes, refused, err := s.admitOrDeferWorkflowStepEnsure(ctx, taskID, sessionID, workflowStepID) + preConsultRes, refused, err := s.admitOrDeferWorkflowStepEnsureWithBinding(ctx, taskID, sessionID, workflowStepID, entryBinding) if err != nil { return err } @@ -3210,8 +3289,29 @@ func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessi } defer preConsultRes.releaseIfNotConsumed() s.recordManualOverrideIfAdmitted(ctx, taskID, sessionID, preConsultRes.manualOverride, preConsultRes.population, preConsultRes.populationKnown, preConsultRes.ceiling) + if replayWorkflowStepBinding { + if err := s.validateClaimedCeilingBinding(ctx, taskID, entryBinding); err != nil { + return err + } + } s.advanceTaskWorkflowStep(ctx, dbTask, workflowStepID, session) + if derivedEntryBinding || replayWorkflowStepBinding { + // The pre-consultation binding identifies the requested destination while + // the task still points at its source step. Once admission succeeds, the + // step transition allocates the committed entry identity. Carry that new + // identity into the prompt and dispatch guards; otherwise a legitimate + // transition would look like a successor replacing the very entry it just + // committed. + if latestTask, reloadErr := s.repo.GetTask(ctx, taskID); reloadErr == nil && latestTask != nil { + if latestBinding, bound := s.workflowEntryBindingForStep(ctx, taskID, step, sessionID); bound { + entryBinding = latestBinding + ctx = withCeilingEntryBinding(ctx, entryBinding) + } else { + entryBinding = nil + } + } + } effectivePrompt, err := s.buildWorkflowEntryPrompt( ctx, dbTask.Description, step, taskID, sessionID, session.IsPassthrough, @@ -3220,7 +3320,7 @@ func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessi return fmt.Errorf("failed to build workflow prompt: %w", err) } - if err := s.ensureSessionRunning(ctx, sessionID, session, launchOriginAutomatic); err != nil { + if err := s.ensureSessionRunningWithBinding(ctx, sessionID, session, launchOriginAutomatic, entryBinding); err != nil { return err } preConsultRes.consume() @@ -3259,6 +3359,7 @@ func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessi promptAlreadyComposed: true, fallbackLaunchPrompt: composedLaunchPrompt, fallbackRetryPrompt: effectivePrompt, + ceilingEntryBinding: entryBinding, }) if err != nil { return fmt.Errorf("failed to prompt session: %w", err) @@ -3317,6 +3418,38 @@ func (s *Service) ensureSessionRunningWithAttempt( session *models.TaskSession, origin launchOrigin, ) (*resumeAttempt, error) { + return s.ensureSessionRunningWithAttemptAndBinding( + ctx, sessionID, session, origin, ceilingEntryBindingFromContext(ctx), + ) +} + +func (s *Service) ensureSessionRunningWithBinding( + ctx context.Context, + sessionID string, + session *models.TaskSession, + origin launchOrigin, + binding *models.CeilingWorkflowEntryBinding, +) error { + attempt, err := s.ensureSessionRunningWithAttemptAndBinding(ctx, sessionID, session, origin, binding) + if attempt != nil { + attempt.finish(s.resumeAttemptStore()) + } + return err +} + +func (s *Service) ensureSessionRunningWithAttemptAndBinding( + ctx context.Context, + sessionID string, + session *models.TaskSession, + origin launchOrigin, + binding *models.CeilingWorkflowEntryBinding, +) (*resumeAttempt, error) { + if binding != nil { + ctx = withCeilingEntryBinding(ctx, binding) + if err := s.validateClaimedCeilingBinding(ctx, session.TaskID, binding); err != nil { + return nil, err + } + } releaseLifecycleLock := s.acquireSessionLifecycleLock(sessionID) defer releaseLifecycleLock() @@ -3399,6 +3532,9 @@ func (s *Service) runResumeAttempt( s.logger.Debug("agent not running for session, attempting resume", zap.String("session_id", sessionID), zap.String("session_state", string(session.State))) + if err := s.validateContextCeilingEntry(resumeCtx, session.TaskID); err != nil { + return s.finishResumeAttemptWithError(startupAttempt, err) + } return s.coldResumeSession(resumeCtx, sessionID, session, isOfficeTask, startupAttempt, origin) } @@ -3467,6 +3603,9 @@ func (s *Service) coldResumeSession( startupAttempt *resumeAttempt, origin launchOrigin, ) (*resumeAttempt, error) { + if err := s.validateContextCeilingEntry(ctx, session.TaskID); err != nil { + return s.finishResumeAttemptWithError(startupAttempt, err) + } seam3Res, refusal := s.admitSeam3(ctx, session.TaskID, sessionID, origin) if refusal != nil { return startupAttempt, refusal @@ -3549,6 +3688,9 @@ func (s *Service) attemptColdResume( if session.State == models.TaskSessionStateCreated { hasRunning, _ := s.repo.HasExecutorRunningRow(ctx, sessionID) if hasRunning { + if err := s.validateContextCeilingEntry(ctx, session.TaskID); err != nil { + return false, err + } return false, s.startAgentOnPreparedWorkspace(ctx, sessionID, session, resumeAttempt) } } @@ -3576,6 +3718,9 @@ func (s *Service) attemptColdResume( if executor.IsCancellableResumeContext(ctx) { resumeCtx = ctx } + if err := s.validateContextCeilingEntry(resumeCtx, session.TaskID); err != nil { + return false, err + } execution, launchErr := s.executor.ResumeSession(resumeCtx, session, true) if execution != nil && resumeAttempt != nil { resumeAttempt.setExecutionID(execution.AgentExecutionID) @@ -3757,9 +3902,15 @@ func (s *Service) startAgentOnPreparedWorkspace( if err != nil { return fmt.Errorf("failed to get task for prepared session: %w", err) } + if err := s.validateContextCeilingEntry(launchCtx, session.TaskID); err != nil { + return err + } if _, err := s.ClaimTaskTitleSession(launchCtx, session.TaskID, sessionID); err != nil { return fmt.Errorf("failed to claim first-turn task title: %w", err) } + if err := s.validateContextCeilingEntry(launchCtx, session.TaskID); err != nil { + return err + } var execution *executor.TaskExecution if execution, err = s.launchPreparedSessionWithDynamicFallback(launchCtx, task, sessionID, executor.LaunchOptions{ AgentProfileID: session.AgentProfileID, @@ -3900,6 +4051,7 @@ func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID st resp.State = string(session.State) resp.UpdatedAt = session.UpdatedAt.UTC().Format(time.RFC3339Nano) resp.AgentProfileID = session.AgentProfileID + resp.AutoResumeAllowed, resp.AutoResumeBlockedReason = s.autoResumeEligibility(ctx, task, session) if task.ArchivedAt != nil { resp.ResumeReason = resumeReasonTaskArchived return resp, nil @@ -3995,6 +4147,84 @@ func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID st return evaluateFreshStartResume(session, running, runErr, resp), nil } +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. +// +//nolint:cyclop // Passive recovery checks each ownership source independently. +func (s *Service) autoResumeEligibility( + ctx context.Context, task *models.Task, session *models.TaskSession, +) (bool, string) { + 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, "" + } + record, ok := raw.(map[string]interface{}) + if !ok { + return false, autoResumeBlockedOwnershipUnavailable + } + deferral, err := models.ReadCeilingDeferral(record) + if err != nil { + return false, autoResumeBlockedOwnershipUnavailable + } + if deferral.Kind == models.CeilingLaunchStart { + // Seam 1 can defer before a destination session exists. Once a route is + // committed, the route destination is the only session that may own this + // record. Do not let passive recovery wake an existing predecessor while + // the sessionless workflow start is waiting, and fail closed when the + // route/binding cannot identify that destination. + destinationID := models.CeilingDeferralSessionID(task, deferral) + if destinationID == "" || !models.CeilingDeferralTargetsSession(task, deferral, destinationID) { + return false, autoResumeBlockedOwnershipUnavailable + } + if destinationID == session.ID { + return false, autoResumeBlockedLaunchQueued + } + return true, "" + } + destinationID := models.CeilingDeferralSessionID(task, deferral) + if destinationID == "" || !models.CeilingDeferralTargetsSession(task, deferral, destinationID) { + return false, autoResumeBlockedOwnershipUnavailable + } + if destinationID == session.ID { + return false, autoResumeBlockedLaunchQueued + } + // A valid deferral owned by another session does not block passive recovery + // of this session. The queue projection is task-scoped and names its exact + // destination separately. + return true, "" +} + // evaluateFreshStartResume checks whether a session without a resume token can be // started fresh. Sessions in error-recovery state (non-empty ErrorMessage) are marked // resumable but not auto-resumed, so the user sees the error and can choose via action buttons. @@ -5382,6 +5612,9 @@ type promptTaskOptions struct { // ownership record. The outer resume operation finishes it after provider // acceptance or the retry's terminal result. resumeAttempt *resumeAttempt + // ceilingEntryBinding pins replayed workflow work to the committed route + // and destination that admitted it. + ceilingEntryBinding *models.CeilingWorkflowEntryBinding } type promptDispatchOutcome struct { @@ -5483,6 +5716,15 @@ func (promptTaskOptions) failureContext(ctx context.Context) (context.Context, c // block on an agent turn. func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prompt string, model string, planMode bool, attachments []v1.MessageAttachment, dispatchOnly bool, origin launchOrigin, options promptTaskOptions) (*PromptResult, error) { s.logPromptTaskCall(taskID, sessionID, prompt, model, planMode, attachments, dispatchOnly) + if options.ceilingEntryBinding == nil { + options.ceilingEntryBinding = ceilingEntryBindingFromContext(ctx) + } + if options.ceilingEntryBinding != nil { + ctx = withCeilingEntryBinding(ctx, options.ceilingEntryBinding) + } + if bindingErr := s.validateContextCeilingEntry(ctx, taskID); bindingErr != nil { + return nil, bindingErr + } // Check resume-attempt ownership before any repository read can observe a // compound resume's cancellation as a generic context error. if err := s.validatePromptTaskPreconditions(sessionID, options.resumeAttempt); err != nil { @@ -5961,6 +6203,16 @@ func (s *Service) validateAndRunDispatchBoundary( ) return promptCtx, nil, result, err } + if bindingErr := s.validateContextCeilingEntry(promptCtx, taskID); bindingErr != nil { + if releaseDispatchGuard != nil { + releaseDispatchGuard() + } + failureCtx, cancel := options.failureContext(ctx) + defer cancel() + s.rollbackForegroundDispatchOnFailure(failureCtx, taskID, sessionID, foregroundDispatch) + s.rollbackPromptClaim(failureCtx, taskID, sessionID, rollback) + return promptCtx, nil, nil, bindingErr + } if boundaryErr := runBeforeDispatch(); boundaryErr != nil { if releaseDispatchGuard != nil { releaseDispatchGuard() diff --git a/apps/backend/internal/orchestrator/task_operations_resume_test.go b/apps/backend/internal/orchestrator/task_operations_resume_test.go index b11bef40b2..20130d4dd4 100644 --- a/apps/backend/internal/orchestrator/task_operations_resume_test.go +++ b/apps/backend/internal/orchestrator/task_operations_resume_test.go @@ -111,6 +111,136 @@ func TestGetTaskSessionStatus_AutoResumesNormalWaitingSession(t *testing.T) { } } +func TestAutoResumeEligibilityPreservesSessionOwnership(t *testing.T) { + queuedRecord := models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + metaKeySessionID: "queued-session", + }, + QueuedAt: time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC), + }) + legacyRoute := models.WorkflowSessionRoute{ + OperationID: "route-1", + DestinationStepID: "step-2", + TargetKind: "profile", + SourceSessionID: "parked-session", + DestinationID: "queued-session", + Phase: "committed", + } + + tests := []struct { + name string + taskMetadata map[string]interface{} + sessionID string + sessionMeta map[string]interface{} + primary bool + wantAllowed bool + wantBlockCode string + }{ + { + name: "ordinary session is eligible", + sessionID: "ordinary-session", + wantAllowed: true, + }, + { + name: "durable parking blocks 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, + }, + { + name: "malformed parking blocks conservatively", + sessionID: "parked-session", + sessionMeta: map[string]interface{}{models.SessionMetaKeyWorkflowParking: map[string]interface{}{"stamp": "parking-1"}}, + wantBlockCode: autoResumeBlockedOwnershipUnavailable, + }, + { + name: "queued destination is not passively resumed", + taskMetadata: map[string]interface{}{models.MetaKeyDeferredLaunch: queuedRecord}, + sessionID: "queued-session", + wantBlockCode: autoResumeBlockedLaunchQueued, + }, + { + name: "sessionless workflow start protects its routed destination", + taskMetadata: map[string]interface{}{ + models.MetaKeyDeferredLaunch: models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStart, + Payload: map[string]interface{}{metaKeyPrompt: "workflow prompt"}, + }), + models.MetaKeyWorkflowSessionRoute: legacyRoute, + }, + sessionID: "queued-session", + wantBlockCode: autoResumeBlockedLaunchQueued, + }, + { + name: "sessionless workflow start with no routed destination fails closed", + taskMetadata: map[string]interface{}{ + models.MetaKeyDeferredLaunch: models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStart, + Payload: map[string]interface{}{metaKeyPrompt: "workflow prompt"}, + }), + }, + sessionID: "ordinary-session", + wantBlockCode: autoResumeBlockedOwnershipUnavailable, + }, + { + name: "sibling remains eligible while another destination is queued", + taskMetadata: map[string]interface{}{models.MetaKeyDeferredLaunch: queuedRecord}, + sessionID: "sibling-session", + wantAllowed: true, + }, + { + name: "malformed deferred launch blocks conservatively", + taskMetadata: map[string]interface{}{models.MetaKeyDeferredLaunch: "not-a-record"}, + sessionID: "ordinary-session", + wantBlockCode: autoResumeBlockedOwnershipUnavailable, + }, + { + name: "exact legacy route identifies 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, + }, + { + name: "legacy stop without exact route is ambiguous", + 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, + }, + } + + service := &Service{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + task := &models.Task{Metadata: tt.taskMetadata} + session := &models.TaskSession{ + ID: tt.sessionID, + Metadata: tt.sessionMeta, + IsPrimary: tt.primary, + } + allowed, reason := service.autoResumeEligibility(context.Background(), task, session) + if allowed != tt.wantAllowed { + t.Fatalf("allowed = %t, want %t (reason %q)", allowed, tt.wantAllowed, reason) + } + if reason != tt.wantBlockCode { + t.Fatalf("reason = %q, want %q", reason, tt.wantBlockCode) + } + }) + } +} + // TestGetTaskSessionStatus_AutoResumesFailedSessionWithResumeToken verifies the // failed-but-recoverable path: a FAILED session that still has a resumable // runtime + resume token reports NeedsResume=true so the frontend retries diff --git a/apps/backend/internal/orchestrator/workflow_profile_session_lifecycle.go b/apps/backend/internal/orchestrator/workflow_profile_session_lifecycle.go index e6e88264d8..2d8dbe2711 100644 --- a/apps/backend/internal/orchestrator/workflow_profile_session_lifecycle.go +++ b/apps/backend/internal/orchestrator/workflow_profile_session_lifecycle.go @@ -149,7 +149,7 @@ func (s *Service) parkSessionForProfileSwitchClaimLocked( } } - intent, err := s.recordProfileSwitchStopIntent(ctx, currentSession.ID, executionID) + intent, err := s.recordProfileSwitchStopIntent(ctx, taskID, currentSession.ID, executionID) if err != nil { releaseTeardownClaim() return false, "", err @@ -164,12 +164,12 @@ func (s *Service) parkSessionForProfileSwitchClaimLocked( nil, ) if err != nil { - s.clearParkedProfileSwitchIntent(ctx, currentSession.ID, intent.Stamp) + s.clearWorkflowParkingMarker(ctx, currentSession.ID, intent.Stamp) releaseTeardownClaim() return false, "", fmt.Errorf("park workflow profile session %q: %w", currentSession.ID, err) } if !changed && finalState != models.TaskSessionStateWaitingForInput { - s.clearParkedProfileSwitchIntent(ctx, currentSession.ID, intent.Stamp) + s.clearWorkflowParkingMarker(ctx, currentSession.ID, intent.Stamp) releaseTeardownClaim() return false, "", fmt.Errorf("park workflow profile session %q: state changed to %s", currentSession.ID, finalState) } @@ -211,27 +211,41 @@ func (s *Service) loadProfileSwitchParkCandidate( return currentSession, executionID, nil } -// recordProfileSwitchStopIntent stamps the exact execution before the source -// state changes. The marker is consumed by delayed terminal callbacks and is -// cleared only when the state transition cannot be committed. +// recordProfileSwitchStopIntent stamps the workflow ownership before the +// source state changes. The parking marker is written even without a live +// runtime, so passive inspection can distinguish a deliberately parked +// predecessor from a session that simply became idle after a restart. func (s *Service) recordProfileSwitchStopIntent( ctx context.Context, - sessionID string, + taskID, sessionID string, executionID string, ) (models.WorkflowProfileSwitchStopIntent, error) { - if executionID == "" { - return models.WorkflowProfileSwitchStopIntent{}, nil - } - if _, ok := s.repo.(workflowProfileSwitchStopIntentRemover); !ok { - return models.WorkflowProfileSwitchStopIntent{}, fmt.Errorf("parked workflow profile switch requires stamped session metadata support") - } - if _, ok := s.repo.(workflowProfileSwitchStopIntentMarker); !ok { - return models.WorkflowProfileSwitchStopIntent{}, fmt.Errorf("parked workflow profile switch requires stamped session metadata support") + if executionID != "" { + if _, ok := s.repo.(workflowProfileSwitchStopIntentRemover); !ok { + return models.WorkflowProfileSwitchStopIntent{}, fmt.Errorf("parked workflow profile switch requires stamped session metadata support") + } + if _, ok := s.repo.(workflowProfileSwitchStopIntentMarker); !ok { + return models.WorkflowProfileSwitchStopIntent{}, fmt.Errorf("parked workflow profile switch requires stamped session metadata support") + } } - intent := models.WorkflowProfileSwitchStopIntent{ - ExecutionID: executionID, - Stamp: uuid.NewString(), + stamp := uuid.NewString() + parking := models.WorkflowParking{ + Stamp: stamp, + ParkedAt: time.Now().UTC(), + SourceSessionID: sessionID, + } + task, taskErr := s.repo.GetTask(ctx, taskID) + if taskErr == nil && task != nil { + s.populateWorkflowParkingFromRoute(ctx, task, &parking) + } + if err := s.repo.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyWorkflowParking, parking); err != nil { + return models.WorkflowProfileSwitchStopIntent{}, fmt.Errorf("record parked workflow profile session: %w", err) + } + + intent := models.WorkflowProfileSwitchStopIntent{ExecutionID: executionID, Stamp: stamp} + if executionID == "" { + return intent, nil } if err := s.repo.SetSessionMetadataKey( ctx, @@ -239,11 +253,37 @@ func (s *Service) recordProfileSwitchStopIntent( models.SessionMetaKeyWorkflowProfileSwitchStopIntent, intent, ); err != nil { + s.clearWorkflowParkingMarker(ctx, sessionID, stamp) return models.WorkflowProfileSwitchStopIntent{}, fmt.Errorf("record parked workflow profile switch: %w", err) } return intent, nil } +func (s *Service) populateWorkflowParkingFromRoute( + ctx context.Context, + task *models.Task, + parking *models.WorkflowParking, +) { + if task == nil || parking == nil { + return + } + route, ok := models.LoadWorkflowSessionRoute(task.Metadata) + if !ok { + return + } + parking.WorkflowStepID = route.DestinationStepID + parking.RouteOperationID = route.OperationID + parking.EntryIdentity = route.EntryIdentity + parking.DestinationID = route.DestinationID + if s.workflowStepGetter == nil { + return + } + step, stepErr := s.workflowStepGetter.GetStep(ctx, route.DestinationStepID) + if stepErr == nil && step != nil { + parking.WorkflowID = step.WorkflowID + } +} + func (s *Service) stopParkedWorkflowProfileSession(ctx context.Context, sessionID, executionID string) { if s.agentManager == nil || executionID == "" { return @@ -255,7 +295,11 @@ func (s *Service) stopParkedWorkflowProfileSession(ctx context.Context, sessionI } } -func (s *Service) clearParkedProfileSwitchIntent(ctx context.Context, sessionID, stamp string) { +// clearWorkflowParkingMarker removes only the current parking projection. The +// execution stop-intent tombstone is intentionally retained so a delayed +// terminal callback, including one delivered after restart, remains fenced to +// the execution that was deliberately stopped. +func (s *Service) clearWorkflowParkingMarker(ctx context.Context, sessionID, stamp string) { if strings.TrimSpace(stamp) == "" { return } @@ -264,13 +308,11 @@ func (s *Service) clearParkedProfileSwitchIntent(ctx context.Context, sessionID, return } if _, err := remover.RemoveSessionMetadataKeyIfStamp( - ctx, - sessionID, - models.SessionMetaKeyWorkflowProfileSwitchStopIntent, - stamp, + ctx, sessionID, models.SessionMetaKeyWorkflowParking, stamp, ); err != nil { - s.logger.Warn("failed to clear abandoned parked workflow profile switch intent", + s.logger.Warn("failed to clear workflow parking marker", zap.String("session_id", sessionID), + zap.String("metadata_key", models.SessionMetaKeyWorkflowParking), zap.Error(err)) } } diff --git a/apps/backend/internal/orchestrator/workflow_session_target.go b/apps/backend/internal/orchestrator/workflow_session_target.go index 64f12d2878..d97af8c618 100644 --- a/apps/backend/internal/orchestrator/workflow_session_target.go +++ b/apps/backend/internal/orchestrator/workflow_session_target.go @@ -165,6 +165,8 @@ func (s *Service) resolveBoundSourceWorkflowSession( } func (s *Service) persistWorkflowSessionRoute(ctx context.Context, taskID string, route models.WorkflowSessionRoute) error { + ctx, release := s.lockCeilingEntryAdmission(ctx, taskID) + defer release() setter, ok := s.repo.(taskMetadataKeySetter) if !ok { return nil @@ -233,9 +235,12 @@ func (s *Service) promoteWorkflowSessionRoute( destination *models.TaskSession, route *models.WorkflowSessionRoute, ) (bool, error) { + ctx, release := s.lockCeilingEntryAdmission(ctx, taskID) + defer release() if destination == nil { return false, nil } + destinationParkingStamp := s.captureWorkflowParkingStamp(ctx, destination.ID) if promoter, ok := s.repo.(workflowSessionRoutePromoter); ok && route != nil { committed := *route committed.DestinationID = destination.ID @@ -244,6 +249,7 @@ func (s *Service) promoteWorkflowSessionRoute( if err != nil || !promoted { return promoted, err } + s.clearWorkflowParkingForExplicitExecution(ctx, destination.ID, destinationParkingStamp) s.publishPrimarySessionUpdate(ctx, taskID, destination.ID) return true, nil } @@ -259,10 +265,38 @@ func (s *Service) promoteWorkflowSessionRoute( return false, err } } + // Promotion is the workflow's explicit selection of this destination, + // whether the compatibility path has a route object or only a primary + // session. Clear the selected destination's current parking projection; + // the stamped stop-intent tombstone remains for delayed callbacks. + s.clearWorkflowParkingForExplicitExecution(ctx, destination.ID, destinationParkingStamp) s.publishPrimarySessionUpdate(ctx, taskID, destination.ID) return true, nil } +func (s *Service) clearWorkflowParkingForExplicitExecution( + ctx context.Context, + destinationID string, + authorizedStamp string, +) { + if destinationID == "" || authorizedStamp == "" { + return + } + session, err := s.repo.GetTaskSession(ctx, destinationID) + if err != nil || session == nil { + return + } + parking, ok := models.LoadWorkflowParking(session.Metadata) + if !ok || parking.Stamp != authorizedStamp { + return + } + // The marker was captured from the selected destination before the route + // commit. Its old route identity is expected to differ when a workflow + // returns to a parked session, so the authorization is the exact stamp, not + // the newly committed route. + s.clearWorkflowParkingMarker(ctx, destinationID, authorizedStamp) +} + func workflowSessionRouteMatchesStep(route *models.WorkflowSessionRoute, step *wfmodels.WorkflowStep, profileID string) bool { if route == nil || step == nil || step.SessionTarget == nil { return false @@ -282,6 +316,16 @@ func (s *Service) reuseRecordedWorkflowSession( endPolicy models.WorkflowProfileSessionEndPolicy, ) (*models.TaskSession, bool, error) { if recordedRoute.Phase == workflowSessionRouteCommitted { + // A committed route can be replayed after the selected session was + // parked (for example after a restart). Reusing that exact destination + // is an authorized workflow activation, so clear only its current + // parking projection. The execution stop-intent tombstone remains the + // fence for any delayed callback from the earlier park. + s.clearWorkflowParkingForExplicitExecution( + ctx, + recordedSession.ID, + s.captureWorkflowParkingStamp(ctx, recordedSession.ID), + ) return recordedSession, recordedSession.ID != currentSession.ID, nil } if recordedSession.ID == currentSession.ID { @@ -351,7 +395,7 @@ func (s *Service) workflowEntryIdentity(ctx context.Context, taskID string, entr return "legacy:" + task.UpdatedAt.UTC().Format(time.RFC3339Nano) } } - return "legacy:unknown" + return legacyWorkflowEntryIdentity } func (s *Service) reuseResolvedWorkflowSession( @@ -420,6 +464,7 @@ func (s *Service) prepareExplicitWorkflowSession( baseRoute := models.WorkflowSessionRoute{ OperationID: operationID, DestinationStepID: step.ID, + EntryIdentity: entryIdentity, TargetKind: string(step.SessionTarget.Kind), TargetStepID: step.SessionTarget.StepID, AgentProfileID: targetProfile, diff --git a/apps/backend/internal/orchestrator/workflow_session_target_test.go b/apps/backend/internal/orchestrator/workflow_session_target_test.go index 64c2af625b..f14788cb7d 100644 --- a/apps/backend/internal/orchestrator/workflow_session_target_test.go +++ b/apps/backend/internal/orchestrator/workflow_session_target_test.go @@ -5,6 +5,7 @@ import ( "errors" "sync" "testing" + "time" "github.com/kandev/kandev/internal/task/models" wfmodels "github.com/kandev/kandev/internal/workflow/models" @@ -324,6 +325,58 @@ func TestReusePreparedWorkflowRouteCommitsWhenDestinationIsAlreadyPrimary(t *tes require.Equal(t, workflowSessionRouteCommitted, committed.Phase) } +func TestReuseCommittedWorkflowRouteClearsDestinationParkingOnly(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + destination := &models.TaskSession{ + ID: "workflow-committed-destination", + TaskID: "t1", + AgentProfileID: "profile-a", + State: models.TaskSessionStateWaitingForInput, + } + require.NoError(t, fixture.repo.CreateTaskSession(ctx, destination)) + parking := models.WorkflowParking{ + Stamp: "committed-destination-parking", + ParkedAt: time.Now().UTC(), + SourceSessionID: fixture.current.ID, + } + require.NoError(t, fixture.repo.SetSessionMetadataKey( + ctx, destination.ID, models.SessionMetaKeyWorkflowParking, parking, + )) + stopIntent := models.WorkflowProfileSwitchStopIntent{ + ExecutionID: "committed-destination-execution", + Stamp: parking.Stamp, + } + require.NoError(t, fixture.repo.SetSessionMetadataKey( + ctx, destination.ID, models.SessionMetaKeyWorkflowProfileSwitchStopIntent, stopIntent, + )) + + route := &models.WorkflowSessionRoute{ + OperationID: "workflow-route-committed", + DestinationStepID: "step-review", + EntryIdentity: "entry:00000000000000000031", + TargetKind: string(wfmodels.WorkflowSessionTargetInitial), + AgentProfileID: "profile-a", + DestinationID: destination.ID, + Phase: workflowSessionRouteCommitted, + } + reused, switched, err := fixture.svc.reuseRecordedWorkflowSession( + ctx, "t1", fixture.current, route, destination, models.WorkflowProfileSessionEndPolicyPark, + ) + require.NoError(t, err) + require.True(t, switched) + require.Equal(t, destination.ID, reused.ID) + + updated, err := fixture.repo.GetTaskSession(ctx, destination.ID) + require.NoError(t, err) + _, stillParked := models.LoadWorkflowParking(updated.Metadata) + require.False(t, stillParked, "reusing a committed destination must clear its current parking marker") + gotIntent, intentPresent := workflowProfileSwitchStopIntentFromMetadata(updated.Metadata) + require.True(t, intentPresent, "reusing a parked destination must retain the stop-intent tombstone") + require.Equal(t, stopIntent.ExecutionID, gotIntent.ExecutionID) + require.Equal(t, stopIntent.Stamp, gotIntent.Stamp) +} + func TestWorkflowRouteRetryRecoversAfterLegacyCommitMetadataFailure(t *testing.T) { ctx := context.Background() fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) @@ -379,6 +432,66 @@ func TestWorkflowRouteRetryRecoversAfterLegacyCommitMetadataFailure(t *testing.T require.Equal(t, workflowSessionRouteCommitted, committed.Phase) } +func TestPromoteWorkflowSessionRouteClearsSelectedDestinationParkingOnly(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + destination := &models.TaskSession{ + ID: "workflow-selected-destination", + TaskID: "t1", + AgentProfileID: "profile-a", + State: models.TaskSessionStateCreated, + } + require.NoError(t, fixture.repo.CreateTaskSession(ctx, destination)) + + destinationParking := models.WorkflowParking{ + Stamp: "destination-parking", + ParkedAt: time.Now().UTC(), + SourceSessionID: destination.ID, + } + require.NoError(t, fixture.repo.SetSessionMetadataKey( + ctx, destination.ID, models.SessionMetaKeyWorkflowParking, destinationParking, + )) + require.NoError(t, fixture.repo.SetSessionMetadataKey( + ctx, destination.ID, models.SessionMetaKeyWorkflowProfileSwitchStopIntent, + models.WorkflowProfileSwitchStopIntent{ExecutionID: "destination-execution", Stamp: destinationParking.Stamp}, + )) + sourceParking := models.WorkflowParking{ + Stamp: "source-parking", + ParkedAt: time.Now().UTC(), + SourceSessionID: fixture.current.ID, + } + require.NoError(t, fixture.repo.SetSessionMetadataKey( + ctx, fixture.current.ID, models.SessionMetaKeyWorkflowParking, sourceParking, + )) + + route := models.WorkflowSessionRoute{ + OperationID: "workflow-route-selected", + DestinationStepID: "step-review", + EntryIdentity: "entry:00000000000000000021", + TargetKind: string(wfmodels.WorkflowSessionTargetInitial), + AgentProfileID: "profile-a", + DestinationID: destination.ID, + Phase: workflowSessionRoutePrepared, + } + require.NoError(t, fixture.repo.SetTaskMetadataKey(ctx, "t1", models.MetaKeyWorkflowSessionRoute, route)) + promoted, err := fixture.svc.promoteWorkflowSessionRoute(ctx, "t1", destination, &route) + require.NoError(t, err) + require.True(t, promoted) + + selected, err := fixture.repo.GetTaskSession(ctx, destination.ID) + require.NoError(t, err) + _, selectedStillParked := models.LoadWorkflowParking(selected.Metadata) + require.False(t, selectedStillParked, "selected destination parking marker was not cleared") + _, stopIntentStillPresent := workflowProfileSwitchStopIntentFromMetadata(selected.Metadata) + require.True(t, stopIntentStillPresent, "selected destination stop tombstone was cleared with parking") + + source, err := fixture.repo.GetTaskSession(ctx, fixture.current.ID) + require.NoError(t, err) + parking, sourceStillParked := models.LoadWorkflowParking(source.Metadata) + require.True(t, sourceStillParked) + require.Equal(t, sourceParking.Stamp, parking.Stamp, "promotion cleared the source marker instead of destination") +} + func fixtureSessionState(t *testing.T, repo interface { GetTaskSession(context.Context, string) (*models.TaskSession, error) }, sessionID string) models.TaskSessionState { diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index ab9534142a..e2de2098aa 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -651,7 +651,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w } // Transition task from REVIEW → IN_PROGRESS if needed - task, err := h.ensureTaskInProgress(ctx, req.TaskID) + task, err := h.ensureTaskInProgress(ctx, req.TaskID, req.TaskSessionID) if err != nil { h.logger.Error("failed to get task", zap.String("task_id", req.TaskID), zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get task", nil) @@ -1281,10 +1281,14 @@ func (h *MessageHandlers) logBlockedRunningSession(sessionID string, state model zap.String("session_state", string(state))) } -// ensureTaskInProgress fetches the task and transitions it from REVIEW → IN_PROGRESS if needed. +// ensureTaskInProgress fetches the task and transitions it from REVIEW to +// IN_PROGRESS if needed. A task whose automatic destination is still waiting +// for session capacity remains in Scheduling until real work starts. // The fetched task is returned so message context injection can reuse the same // snapshot instead of issuing another repository lookup. -func (h *MessageHandlers) ensureTaskInProgress(ctx context.Context, taskID string) (*models.Task, error) { +// +//nolint:cyclop,gocognit,nestif // REVIEW reconciliation keeps queue and task CAS checks together. +func (h *MessageHandlers) ensureTaskInProgress(ctx context.Context, taskID, sessionID string) (*models.Task, error) { task, err := h.service.GetTask(ctx, taskID) if err != nil { return nil, err @@ -1292,6 +1296,11 @@ func (h *MessageHandlers) ensureTaskInProgress(ctx context.Context, taskID strin if task.State != v1.TaskStateReview { return task, nil } + if task, keptQueued, err := h.reconcileQueuedMessageTask(ctx, task, taskID, sessionID); err != nil { + return nil, err + } else if keptQueued { + return task, nil + } if _, err := h.service.UpdateTaskState(ctx, taskID, v1.TaskStateInProgress); err != nil { h.logger.Error("failed to transition task from REVIEW to IN_PROGRESS", zap.String("task_id", taskID), @@ -1303,6 +1312,94 @@ func (h *MessageHandlers) ensureTaskInProgress(ctx context.Context, taskID strin return task, nil } +//nolint:cyclop,gocognit,nestif // Queue reconciliation keeps the read/compare/CAS fence in one transaction-shaped helper. +func (h *MessageHandlers) reconcileQueuedMessageTask( + ctx context.Context, + task *models.Task, + taskID, sessionID string, +) (*models.Task, bool, error) { + // A REVIEW task can still own an automatic launch that is waiting for + // capacity. Read that queue for every message path, including callers that + // do not provide a session id. A repository failure is uncertainty, not an + // absence, so state must remain unchanged and the error must be returned. + deferral, queued, err := h.service.ReadCeilingDeferredLaunch(ctx, taskID) + if err != nil { + return nil, false, err + } + if !queued { + return task, false, nil + } + queuedSessionID := models.CeilingDeferralSessionID(task, deferral) + if queuedSessionID == "" { + return task, true, nil + } + // A caller targeting another session is an explicit message to that + // session. It must not claim or preserve a different queued destination. + if sessionID != "" && sessionID != queuedSessionID { + return task, false, nil + } + if !models.CeilingDeferralTargetsSession(task, deferral, queuedSessionID) { + return task, true, nil + } + queuedSession, err := h.service.GetTaskSession(ctx, queuedSessionID) + if err != nil { + return nil, false, err + } + if queuedSession == nil || queuedSession.TaskID != taskID { + return task, true, nil + } + if queuedSession.State != models.TaskSessionStateCreated { + return task, false, nil + } + + // Re-read the queue and route immediately before the state CAS. A successor + // entry must not inherit the old destination's repair. + latestDeferral, latestQueued, err := h.service.ReadCeilingDeferredLaunch(ctx, taskID) + if err != nil { + return nil, false, err + } + if !latestQueued { + return task, false, nil + } + sameEntry, err := models.CeilingDeferralsEquivalentForAdmission(deferral, latestDeferral) + if err != nil { + return task, true, nil + } + latestTask, err := h.service.GetTask(ctx, taskID) + if err != nil { + return nil, false, err + } + latestSessionID := models.CeilingDeferralSessionID(latestTask, latestDeferral) + if !sameEntry || latestSessionID != queuedSessionID || + !models.CeilingDeferralTargetsSession(latestTask, latestDeferral, queuedSessionID) { + return task, true, nil + } + task = latestTask + latestSession, err := h.service.GetTaskSession(ctx, queuedSessionID) + if err != nil { + return nil, false, err + } + if latestSession == nil || latestSession.TaskID != taskID { + return task, true, nil + } + if latestSession.State != models.TaskSessionStateCreated { + return task, false, nil + } + + h.logger.Info("keeping task in Scheduling while message targets a capacity-deferred session", + zap.String("task_id", taskID), zap.String("session_id", queuedSessionID)) + updated, err := h.service.UpdateTaskStateIfCurrentIn( + ctx, taskID, v1.TaskStateScheduling, []v1.TaskState{v1.TaskStateReview}, + ) + if err != nil { + return nil, false, err + } + if updated { + task.State = v1.TaskStateScheduling + } + return task, true, nil +} + // dispatchPromptAsync forwards the message to the agent as a prompt in a // background goroutine. The caller (wsAddMessage) is responsible for running // on_turn_start synchronously BEFORE wrapping the prompt, so this function diff --git a/apps/backend/internal/task/handlers/message_handlers_ceiling_test.go b/apps/backend/internal/task/handlers/message_handlers_ceiling_test.go new file mode 100644 index 0000000000..926123f0ca --- /dev/null +++ b/apps/backend/internal/task/handlers/message_handlers_ceiling_test.go @@ -0,0 +1,180 @@ +package handlers + +import ( + "context" + "errors" + "maps" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// ceilingReconcileRepo keeps the task/queue/session rows in one small fake so +// the message admission path can be tested against the same authoritative +// values on every read. The embedded mock supplies the unrelated repository +// surface required by task.Service. +type ceilingReconcileRepo struct { + mockRepository + + mu sync.Mutex + task *models.Task + session *models.TaskSession + deferred map[string]interface{} + deferredErr error + stateWrites int +} + +func (r *ceilingReconcileRepo) GetTask(_ context.Context, id string) (*models.Task, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.task == nil || r.task.ID != id { + return nil, errors.New("task not found") + } + task := *r.task + task.Metadata = maps.Clone(r.task.Metadata) + return &task, nil +} + +func (r *ceilingReconcileRepo) GetTaskDeferredLaunch( + _ context.Context, _ string, +) (map[string]interface{}, interface{}, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.deferredErr != nil { + return nil, nil, r.deferredErr + } + return maps.Clone(r.deferred), nil, nil +} + +func (r *ceilingReconcileRepo) GetTaskSession(_ context.Context, id string) (*models.TaskSession, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.session == nil || r.session.ID != id { + return nil, errors.New("session not found") + } + session := *r.session + session.Metadata = maps.Clone(r.session.Metadata) + return &session, nil +} + +func (r *ceilingReconcileRepo) UpdateTaskStateIfCurrentIn( + _ context.Context, id string, state v1.TaskState, allowed []v1.TaskState, +) (v1.TaskState, bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.task == nil || r.task.ID != id { + return "", false, errors.New("task not found") + } + for _, candidate := range allowed { + if r.task.State != candidate { + continue + } + old := r.task.State + r.task.State = state + r.stateWrites++ + return old, true, nil + } + return r.task.State, false, nil +} + +func newCeilingReconcileHandlers(t *testing.T, repo *ceilingReconcileRepo) *MessageHandlers { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{ + Tasks: repo, + TaskRepos: repo, + Sessions: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + return NewMessageHandlers(svc, nil, log) +} + +func TestEnsureTaskInProgressFailsClosedWhenCeilingQueueReadFails(t *testing.T) { + repo := &ceilingReconcileRepo{ + task: &models.Task{ + ID: "message-read-error", State: v1.TaskStateReview, + CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), + }, + deferredErr: errors.New("deferred launch storage unavailable"), + } + handlers := newCeilingReconcileHandlers(t, repo) + + task, err := handlers.ensureTaskInProgress(context.Background(), repo.task.ID, "session-1") + require.Error(t, err) + require.Nil(t, task) + + repo.mu.Lock() + defer repo.mu.Unlock() + require.Equal(t, v1.TaskStateReview, repo.task.State) + require.Zero(t, repo.stateWrites) +} + +func TestEnsureTaskInProgressRepairsLegacyReviewQueuedCreatedSession(t *testing.T) { + queuedAt := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + repo := &ceilingReconcileRepo{ + task: &models.Task{ + ID: "message-legacy-review", State: v1.TaskStateReview, + CreatedAt: queuedAt, UpdatedAt: queuedAt, + }, + session: &models.TaskSession{ + ID: "message-legacy-session", TaskID: "message-legacy-review", + State: models.TaskSessionStateCreated, + }, + deferred: models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + "session_id": "message-legacy-session", + }, + Origin: "automatic", ReasonCode: "session_capacity", + QueuedAt: queuedAt, Ceiling: 5, Population: 6, PopulationKnown: true, + }), + } + handlers := newCeilingReconcileHandlers(t, repo) + + task, err := handlers.ensureTaskInProgress( + context.Background(), repo.task.ID, repo.session.ID, + ) + require.NoError(t, err) + require.Equal(t, v1.TaskStateScheduling, task.State) + + repo.mu.Lock() + defer repo.mu.Unlock() + require.Equal(t, v1.TaskStateScheduling, repo.task.State) + require.Equal(t, 1, repo.stateWrites) +} + +func TestEnsureTaskInProgressRepairsLegacyReviewWithoutSessionArgument(t *testing.T) { + queuedAt := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + repo := &ceilingReconcileRepo{ + task: &models.Task{ + ID: "message-legacy-review-no-session", State: v1.TaskStateReview, + CreatedAt: queuedAt, UpdatedAt: queuedAt, + }, + session: &models.TaskSession{ + ID: "message-legacy-session-no-arg", TaskID: "message-legacy-review-no-session", + State: models.TaskSessionStateCreated, + }, + deferred: models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{"session_id": "message-legacy-session-no-arg"}, + Origin: "automatic", ReasonCode: "session_capacity", QueuedAt: queuedAt, + }), + } + handlers := newCeilingReconcileHandlers(t, repo) + + task, err := handlers.ensureTaskInProgress(context.Background(), repo.task.ID, "") + require.NoError(t, err) + require.Equal(t, v1.TaskStateScheduling, task.State) + + repo.mu.Lock() + defer repo.mu.Unlock() + require.Equal(t, v1.TaskStateScheduling, repo.task.State) + require.Equal(t, 1, repo.stateWrites) +} diff --git a/apps/backend/internal/task/models/ceiling_entry.go b/apps/backend/internal/task/models/ceiling_entry.go new file mode 100644 index 0000000000..f44dfa83bd --- /dev/null +++ b/apps/backend/internal/task/models/ceiling_entry.go @@ -0,0 +1,162 @@ +package models + +import ( + "encoding/json" + "fmt" + "time" +) + +// CeilingWorkflowEntryBinding identifies the workflow entry that owns a +// deferred launch. The binding is nested in the replay payload so the payload +// remains one immutable launch description while the record's bookkeeping can +// change during capacity retries. +type CeilingWorkflowEntryBinding struct { + WorkflowID string `json:"workflow_id"` + DestinationStepID string `json:"destination_step_id"` + RouteOperationID string `json:"route_operation_id"` + EntryIdentity string `json:"entry_identity"` + DestinationSessionID string `json:"destination_session_id,omitempty"` +} + +// Valid reports whether the binding has the identity needed to compare a +// deferred workflow entry with the current committed route. A session ID is +// optional for session-less step starts, where the destination is created only +// after admission. +func (b CeilingWorkflowEntryBinding) Valid() bool { + return b.WorkflowID != "" && + b.DestinationStepID != "" && + b.RouteOperationID != "" && + b.EntryIdentity != "" +} + +// ReadCeilingWorkflowEntryBinding decodes the optional nested binding from a +// replay payload. An absent binding is not an error because older records are +// handled by the legacy unambiguous-route recovery path. +func ReadCeilingWorkflowEntryBinding(payload map[string]interface{}) (CeilingWorkflowEntryBinding, bool, error) { + if payload == nil { + return CeilingWorkflowEntryBinding{}, false, nil + } + raw, ok := payload[CeilingLaunchEntryBindingKey] + if !ok || raw == nil { + return CeilingWorkflowEntryBinding{}, false, nil + } + encoded, err := json.Marshal(raw) + if err != nil { + return CeilingWorkflowEntryBinding{}, true, fmt.Errorf("encode workflow entry binding: %w", err) + } + var binding CeilingWorkflowEntryBinding + if err := json.Unmarshal(encoded, &binding); err != nil { + return CeilingWorkflowEntryBinding{}, true, fmt.Errorf("decode workflow entry binding: %w", err) + } + if !binding.Valid() { + return CeilingWorkflowEntryBinding{}, true, fmt.Errorf("workflow entry binding is incomplete") + } + return binding, true, nil +} + +// CeilingLaunchClaim is the short-lived ownership lease for one ceiling +// launch. A zero deadline is treated as expired so records written before +// leases were introduced remain recoverable. +type CeilingLaunchClaim struct { + ID string + Owner string + ExpiresAt time.Time +} + +func (c CeilingLaunchClaim) Expired(now time.Time) bool { + return c.ExpiresAt.IsZero() || !c.ExpiresAt.After(now) +} + +// ReadCeilingLaunchClaimDetails reads the short-lived CAS claim and its lease +// deadline from a ceiling record. Unknown or malformed values are treated as +// absent so a damaged claim cannot make an otherwise valid launch permanently +// undispatchable. +func ReadCeilingLaunchClaimDetails(record map[string]interface{}) (CeilingLaunchClaim, bool) { + if record == nil { + return CeilingLaunchClaim{}, false + } + raw, ok := record[CeilingLaunchClaimKey] + if !ok || raw == nil { + return CeilingLaunchClaim{}, false + } + encoded, err := json.Marshal(raw) + if err != nil { + return CeilingLaunchClaim{}, false + } + var claim struct { + ID string `json:"id"` + Owner string `json:"owner"` + ExpiresAt string `json:"expires_at"` + } + if err := json.Unmarshal(encoded, &claim); err != nil { + return CeilingLaunchClaim{}, false + } + if claim.ID == "" || claim.Owner == "" { + return CeilingLaunchClaim{}, false + } + var expiresAt time.Time + if claim.ExpiresAt != "" { + expiresAt, _ = time.Parse(time.RFC3339Nano, claim.ExpiresAt) + } + return CeilingLaunchClaim{ID: claim.ID, Owner: claim.Owner, ExpiresAt: expiresAt}, true +} + +// ReadCeilingLaunchClaim preserves the compact claim reader used by callers +// that only need the owner identity. +func ReadCeilingLaunchClaim(record map[string]interface{}) (claimID, owner string, ok bool) { + claim, ok := ReadCeilingLaunchClaimDetails(record) + if !ok { + return "", "", false + } + return claim.ID, claim.Owner, true +} + +// CeilingDeferralSessionID returns the durable destination session for a +// ceiling launch when one exists. Seam-1 start records predate a session and +// therefore resolve their destination from the task's committed route. +func CeilingDeferralSessionID(task *Task, deferral CeilingDeferral) string { + if binding, present, err := ReadCeilingWorkflowEntryBinding(deferral.Payload); err == nil && present { + if binding.DestinationSessionID != "" { + return binding.DestinationSessionID + } + } else if err != nil { + return "" + } + if deferral.Kind != CeilingLaunchStart { + value, _ := deferral.Payload["session_id"].(string) + return value + } + if task == nil { + return "" + } + route, present := LoadWorkflowSessionRoute(task.Metadata) + if !present { + return "" + } + return route.DestinationID +} + +// CeilingDeferralTargetsSession checks the destination and, when a nested +// binding is present, the committed route identity. It is used by message +// admission as well as replay so an old task snapshot cannot preserve REVIEW +// or consume a queue entry for a successor workflow entry. +func CeilingDeferralTargetsSession(task *Task, deferral CeilingDeferral, sessionID string) bool { + if task == nil || sessionID == "" || CeilingDeferralSessionID(task, deferral) != sessionID { + return false + } + binding, present, err := ReadCeilingWorkflowEntryBinding(deferral.Payload) + if err != nil || !present { + return err == nil + } + route, routePresent := LoadWorkflowSessionRoute(task.Metadata) + if !routePresent { + // A bound record must be compared with the committed route. The task's + // step fields are not an entry identity and can already describe a + // successor after the original route was replaced. + return false + } + return route.OperationID == binding.RouteOperationID && + route.DestinationStepID == binding.DestinationStepID && + route.EntryIdentity == binding.EntryIdentity && + (route.DestinationID == "" || binding.DestinationSessionID == "" || route.DestinationID == binding.DestinationSessionID) +} diff --git a/apps/backend/internal/task/models/ceiling_entry_test.go b/apps/backend/internal/task/models/ceiling_entry_test.go new file mode 100644 index 0000000000..3aee6f8859 --- /dev/null +++ b/apps/backend/internal/task/models/ceiling_entry_test.go @@ -0,0 +1,105 @@ +package models + +import "testing" + +func TestReadCeilingWorkflowEntryBinding(t *testing.T) { + payload := map[string]interface{}{ + CeilingLaunchEntryBindingKey: map[string]interface{}{ + "workflow_id": "workflow-1", + "destination_step_id": "step-2", + "route_operation_id": "route-1", + "entry_identity": "entry:00000000000000000007", + "destination_session_id": "session-2", + }, + } + binding, present, err := ReadCeilingWorkflowEntryBinding(payload) + if err != nil || !present { + t.Fatalf("ReadCeilingWorkflowEntryBinding() = %#v, %v, %v", binding, present, err) + } + if binding.DestinationSessionID != "session-2" || !binding.Valid() { + t.Fatalf("binding = %#v", binding) + } +} + +func TestReadCeilingLaunchClaim(t *testing.T) { + claimID, owner, ok := ReadCeilingLaunchClaim(map[string]interface{}{ + CeilingLaunchClaimKey: map[string]interface{}{"id": "claim-1", "owner": "send_now"}, + }) + if !ok || claimID != "claim-1" || owner != "send_now" { + t.Fatalf("claim = %q, %q, %v", claimID, owner, ok) + } +} + +func TestCeilingDeferralSessionIDResolvesSessionlessStartFromRoute(t *testing.T) { + task := &Task{ + WorkflowStepID: "step-new", + Metadata: map[string]interface{}{MetaKeyWorkflowSessionRoute: WorkflowSessionRoute{ + OperationID: "route-new", + DestinationStepID: "step-new", + TargetKind: "new_session", + DestinationID: "session-new", + Phase: "committed", + }}, + } + deferral := CeilingDeferral{Kind: CeilingLaunchStart, Payload: map[string]interface{}{}} + if got := CeilingDeferralSessionID(task, deferral); got != "session-new" { + t.Fatalf("sessionless start destination = %q, want session-new", got) + } + if !CeilingDeferralTargetsSession(task, deferral, "session-new") { + t.Fatal("sessionless start should target the committed route destination") + } +} + +func TestCeilingDeferralTargetsSessionRejectsSuccessorRoute(t *testing.T) { + task := &Task{ + WorkflowID: "workflow-1", + WorkflowStepID: "step-new", + Metadata: map[string]interface{}{MetaKeyWorkflowSessionRoute: WorkflowSessionRoute{ + OperationID: "route-new", + DestinationStepID: "step-new", + EntryIdentity: "entry:new", + TargetKind: "new_session", + DestinationID: "session-new", + Phase: "committed", + }}, + } + deferral := CeilingDeferral{ + Kind: CeilingLaunchStart, + Payload: map[string]interface{}{ + CeilingLaunchEntryBindingKey: map[string]interface{}{ + "workflow_id": "workflow-1", + "destination_step_id": "step-new", + "route_operation_id": "route-old", + "entry_identity": "entry:old", + "destination_session_id": "session-old", + }, + }, + } + if CeilingDeferralTargetsSession(task, deferral, "session-new") { + t.Fatal("old workflow entry must not target the successor route") + } +} + +func TestCeilingDeferralTargetsSessionRejectsBoundRecordWithoutCommittedRoute(t *testing.T) { + task := &Task{ + WorkflowID: "workflow-1", + WorkflowStepID: "step-new", + Metadata: map[string]interface{}{}, + } + deferral := CeilingDeferral{ + Kind: CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + "session_id": "session-new", + CeilingLaunchEntryBindingKey: map[string]interface{}{ + "workflow_id": "workflow-1", + "destination_step_id": "step-new", + "route_operation_id": "route-old", + "entry_identity": "entry:old", + "destination_session_id": "session-new", + }, + }, + } + if CeilingDeferralTargetsSession(task, deferral, "session-new") { + t.Fatal("a bound deferred entry without a committed route must be unavailable") + } +} diff --git a/apps/backend/internal/task/models/ceiling_equivalence.go b/apps/backend/internal/task/models/ceiling_equivalence.go index c6aad917c1..b7ee77adf6 100644 --- a/apps/backend/internal/task/models/ceiling_equivalence.go +++ b/apps/backend/internal/task/models/ceiling_equivalence.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "maps" ) // CeilingDeferralsEquivalent reports whether two deferrals describe the same @@ -35,6 +36,30 @@ func CeilingDeferralsEquivalent(a, b CeilingDeferral) (bool, error) { return bytes.Equal(left, right), nil } +// CeilingDeferralsEquivalentForAdmission compares two observations of one +// deferred launch at an admission boundary. A legacy record may gain its +// workflow-entry binding between reads, so an absent binding and a present +// binding are equivalent only after the optional field is removed from both +// payloads. When both observations carry a binding, the binding remains part +// of the identity and must compare exactly. +func CeilingDeferralsEquivalentForAdmission(a, b CeilingDeferral) (bool, error) { + left := a + right := b + _, leftBound := a.Payload[CeilingLaunchEntryBindingKey] + _, rightBound := b.Payload[CeilingLaunchEntryBindingKey] + if leftBound != rightBound { + left.Payload = comparablePayloadWithoutOptionalBinding(a.Payload) + right.Payload = comparablePayloadWithoutOptionalBinding(b.Payload) + } + return CeilingDeferralsEquivalent(left, right) +} + +func comparablePayloadWithoutOptionalBinding(payload map[string]interface{}) map[string]interface{} { + copyOfPayload := maps.Clone(payload) + delete(copyOfPayload, CeilingLaunchEntryBindingKey) + return copyOfPayload +} + // canonicalComparablePayload renders a payload to its comparison form: marshalled // through JSON so Go types collapse to the wire shape, decoded with number literals // preserved, then stripped of values that carry no information. diff --git a/apps/backend/internal/task/models/ceiling_equivalence_test.go b/apps/backend/internal/task/models/ceiling_equivalence_test.go index 68b46a5715..d2e4b0aea2 100644 --- a/apps/backend/internal/task/models/ceiling_equivalence_test.go +++ b/apps/backend/internal/task/models/ceiling_equivalence_test.go @@ -122,3 +122,39 @@ func TestNormalizationAppliesToBothSides(t *testing.T) { deferralWith(CeilingLaunchStart, fresh), true, "an older record normalizes the same way") } + +func TestAdmissionEquivalenceAllowsLegacyBindingEnrichmentButProtectsSuccessors(t *testing.T) { + legacy := deferralWith(CeilingLaunchStartCreated, map[string]interface{}{ + "session_id": "session-1", "prompt": "workflow", + }) + bound := deferralWith(CeilingLaunchStartCreated, map[string]interface{}{ + "session_id": "session-1", "prompt": "workflow", + CeilingLaunchEntryBindingKey: map[string]interface{}{ + "workflow_id": "workflow-1", "destination_step_id": "step-1", + "route_operation_id": "route-1", "entry_identity": "entry-1", + }, + }) + got, err := CeilingDeferralsEquivalentForAdmission(legacy, bound) + if err != nil { + t.Fatalf("CeilingDeferralsEquivalentForAdmission: %v", err) + } + if !got { + t.Fatal("binding enrichment must not make the same launch appear to be a successor") + } + + successor := bound + successor.Payload = map[string]interface{}{ + "session_id": "session-1", "prompt": "workflow", + CeilingLaunchEntryBindingKey: map[string]interface{}{ + "workflow_id": "workflow-1", "destination_step_id": "step-2", + "route_operation_id": "route-2", "entry_identity": "entry-2", + }, + } + got, err = CeilingDeferralsEquivalentForAdmission(bound, successor) + if err != nil { + t.Fatalf("CeilingDeferralsEquivalentForAdmission successor: %v", err) + } + if got { + t.Fatal("two bound workflow entries must not compare equal") + } +} diff --git a/apps/backend/internal/task/models/ceiling_launch.go b/apps/backend/internal/task/models/ceiling_launch.go index 75f06b60cd..878d432fb2 100644 --- a/apps/backend/internal/task/models/ceiling_launch.go +++ b/apps/backend/internal/task/models/ceiling_launch.go @@ -30,6 +30,21 @@ const ( // the composed prompt and its attachments. CeilingLaunchPayloadKey = "ceiling_launch_payload" + // CeilingLaunchClaimKey is a short-lived compare-and-set claim held by the + // replay or explicit Send Now dispatcher while it owns this exact deferred + // launch. It is prefixed with ceiling_ so WIP and ceiling owners can clear + // their own halves of the shared record independently. + CeilingLaunchClaimKey = "ceiling_launch_claim" + + // CeilingLaunchClaimExpiresAtKey records the UTC lease deadline for an + // in-flight ceiling launch claim. A process that exits while holding a + // claim can therefore be recovered by a later dispatcher. + CeilingLaunchClaimExpiresAtKey = "expires_at" + + // CeilingLaunchEntryBindingKey nests the exact workflow entry identity in + // a workflow-origin replay payload. + CeilingLaunchEntryBindingKey = "workflow_entry_binding" + // CeilingLaunchOriginKey stores the automatic/manual classification so a // deferred automatic launch cannot be re-admitted later as a manual override. CeilingLaunchOriginKey = "ceiling_launch_origin" diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index aa372a31cb..12e7dbea42 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -260,12 +260,15 @@ type WorkflowInitialSessionSnapshot struct { type WorkflowSessionRoute struct { OperationID string `json:"operation_id"` DestinationStepID string `json:"destination_step_id"` - TargetKind string `json:"target_kind"` - TargetStepID string `json:"target_step_id,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - SourceSessionID string `json:"source_session_id,omitempty"` - DestinationID string `json:"destination_session_id,omitempty"` - Phase string `json:"phase"` + // EntryIdentity binds the route to the workflow entry that created it. It + // prevents a late replay from consuming a newer route for the same step. + EntryIdentity string `json:"entry_identity,omitempty"` + TargetKind string `json:"target_kind"` + TargetStepID string `json:"target_step_id,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + SourceSessionID string `json:"source_session_id,omitempty"` + DestinationID string `json:"destination_session_id,omitempty"` + Phase string `json:"phase"` } // LoadWorkflowSessionRoute decodes the bounded route record stored in task diff --git a/apps/backend/internal/task/models/public_metadata.go b/apps/backend/internal/task/models/public_metadata.go index b74d8bfde0..e604f6ef8c 100644 --- a/apps/backend/internal/task/models/public_metadata.go +++ b/apps/backend/internal/task/models/public_metadata.go @@ -29,6 +29,7 @@ func PublicTaskMetadata(metadata map[string]interface{}) map[string]interface{} delete(publicDeferred, DeferredLaunchUserIDKey) delete(publicDeferred, DeferredLaunchRecordRecentUseKey) delete(publicDeferred, CeilingLaunchPayloadKey) + delete(publicDeferred, CeilingLaunchClaimKey) public[MetaKeyDeferredLaunch] = publicDeferred return public } diff --git a/apps/backend/internal/task/models/workflow_parking.go b/apps/backend/internal/task/models/workflow_parking.go new file mode 100644 index 0000000000..dac9df1fab --- /dev/null +++ b/apps/backend/internal/task/models/workflow_parking.go @@ -0,0 +1,49 @@ +package models + +import ( + "encoding/json" + "strings" + "time" +) + +const SessionMetaKeyWorkflowParking = "workflow_parking" + +// WorkflowParking records the workflow ownership of a session that was parked +// while another workflow step became the active destination. The marker is +// durable even when the source has no runtime after a restart. +type WorkflowParking struct { + Stamp string `json:"stamp"` + ParkedAt time.Time `json:"parked_at"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + RouteOperationID string `json:"route_operation_id"` + EntryIdentity string `json:"entry_identity"` + SourceSessionID string `json:"source_session_id"` + DestinationID string `json:"destination_session_id,omitempty"` +} + +// LoadWorkflowParking decodes the bounded parking marker stored in session +// metadata. Invalid or incomplete values are unavailable to callers. +func LoadWorkflowParking(metadata map[string]interface{}) (WorkflowParking, bool) { + if metadata == nil { + return WorkflowParking{}, false + } + value, ok := metadata[SessionMetaKeyWorkflowParking] + if !ok || value == nil { + return WorkflowParking{}, false + } + payload, err := json.Marshal(value) + if err != nil { + return WorkflowParking{}, false + } + var parking WorkflowParking + if err := json.Unmarshal(payload, &parking); err != nil { + return WorkflowParking{}, false + } + if strings.TrimSpace(parking.Stamp) == "" || + parking.ParkedAt.IsZero() || + strings.TrimSpace(parking.SourceSessionID) == "" { + return WorkflowParking{}, false + } + return parking, true +} diff --git a/apps/backend/internal/task/models/workflow_parking_test.go b/apps/backend/internal/task/models/workflow_parking_test.go new file mode 100644 index 0000000000..76b4b0ae59 --- /dev/null +++ b/apps/backend/internal/task/models/workflow_parking_test.go @@ -0,0 +1,63 @@ +package models + +import ( + "testing" + "time" +) + +func TestLoadWorkflowParkingRoundTripsTypedAndJSONMetadata(t *testing.T) { + parkedAt := time.Date(2026, 9, 16, 20, 15, 0, 0, time.UTC) + want := WorkflowParking{ + Stamp: "parking-1", + ParkedAt: parkedAt, + WorkflowID: "workflow-1", + WorkflowStepID: "step-review", + RouteOperationID: "route-1", + EntryIdentity: "entry:00000000000000000042", + SourceSessionID: "session-astra", + DestinationID: "session-luna", + } + + for name, metadata := range map[string]map[string]interface{}{ + "typed": {SessionMetaKeyWorkflowParking: want}, + "json": { + SessionMetaKeyWorkflowParking: map[string]interface{}{ + "stamp": want.Stamp, + "parked_at": want.ParkedAt.Format(time.RFC3339Nano), + "workflow_id": want.WorkflowID, + "workflow_step_id": want.WorkflowStepID, + "route_operation_id": want.RouteOperationID, + "entry_identity": want.EntryIdentity, + "source_session_id": want.SourceSessionID, + "destination_session_id": want.DestinationID, + }, + }, + } { + t.Run(name, func(t *testing.T) { + got, ok := LoadWorkflowParking(metadata) + if !ok { + t.Fatal("LoadWorkflowParking returned unavailable") + } + if got != want { + t.Fatalf("parking = %#v, want %#v", got, want) + } + }) + } +} + +func TestLoadWorkflowParkingRejectsIncompleteMetadata(t *testing.T) { + for index, metadata := range []map[string]interface{}{ + {}, + {SessionMetaKeyWorkflowParking: map[string]interface{}{"stamp": "parking-1"}}, + {SessionMetaKeyWorkflowParking: map[string]interface{}{ + "stamp": "parking-1", + "parked_at": "not-a-time", + }}, + } { + t.Run(string(rune('a'+index)), func(t *testing.T) { + if _, ok := LoadWorkflowParking(metadata); ok { + t.Fatal("incomplete parking metadata was accepted") + } + }) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/step_entries_test.go b/apps/backend/internal/task/repository/sqlite/step_entries_test.go index df95e4a909..4ec3deb726 100644 --- a/apps/backend/internal/task/repository/sqlite/step_entries_test.go +++ b/apps/backend/internal/task/repository/sqlite/step_entries_test.go @@ -113,6 +113,9 @@ func TestUpdateTaskAllocatesStepEntryWhenPendingAllocationPresent(t *testing.T) if holder.EntryID == 0 { t.Fatalf("expected holder.EntryID to be populated, got 0") } + if holder.TransitionID == 0 { + t.Fatalf("expected holder.TransitionID to be populated, got 0") + } if holder.EntrySeq != 1 { t.Fatalf("EntrySeq = %d, want 1", holder.EntrySeq) } diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 523026f781..2981d5dcfb 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -25,6 +25,7 @@ import ( usermodels "github.com/kandev/kandev/internal/user/models" wfmodels "github.com/kandev/kandev/internal/workflow/models" workflowmove "github.com/kandev/kandev/internal/workflow/move" + "github.com/kandev/kandev/internal/workflow/stepentry" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -1227,6 +1228,9 @@ func (r *Repository) updateTaskTx(ctx context.Context, tx *sql.Tx, task *models. } task.WorkflowStepTransitionID = transitionID entryID = formatEntryID(transitionID) + if holder, ok := stepentry.ResultHolderFromContext(ctx); ok { + holder.TransitionID = transitionID + } if transitionID != 0 { task.FromWorkflowID = fromWorkflowID task.FromStepID = fromStepID diff --git a/apps/backend/internal/task/repository/sqlite/workflow_parking_postgres_test.go b/apps/backend/internal/task/repository/sqlite/workflow_parking_postgres_test.go new file mode 100644 index 0000000000..c9aa3ec37d --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/workflow_parking_postgres_test.go @@ -0,0 +1,55 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" + "github.com/stretchr/testify/require" +) + +// TestPostgresWorkflowParking runs the same stamped metadata contract against +// PostgreSQL's JSONB implementation. It is skipped by testutil when the +// isolated PostgreSQL DSN is not configured. +func TestPostgresWorkflowParking(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + require.NoError(t, err) + ctx := context.Background() + seedPostgresTaskSession(t, repo, "workflow-parking-task-pg", "workflow-parking-session-pg") + + first := models.WorkflowParking{ + Stamp: "parking-first-pg", + ParkedAt: time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC), + WorkflowID: "workflow-1", + WorkflowStepID: "step-1", + RouteOperationID: "route-first", + EntryIdentity: "entry-first", + SourceSessionID: "workflow-parking-session-pg", + DestinationID: "destination-first", + } + require.NoError(t, repo.SetSessionMetadataKey( + ctx, "workflow-parking-session-pg", models.SessionMetaKeyWorkflowParking, first, + )) + + second := first + second.Stamp = "parking-second-pg" + second.RouteOperationID = "route-second" + require.NoError(t, repo.SetSessionMetadataKey( + ctx, "workflow-parking-session-pg", models.SessionMetaKeyWorkflowParking, second, + )) + + removed, err := repo.RemoveSessionMetadataKeyIfStamp( + ctx, "workflow-parking-session-pg", models.SessionMetaKeyWorkflowParking, first.Stamp, + ) + require.NoError(t, err) + require.False(t, removed, "stale parking cleanup removed the successor marker") + + removed, err = repo.RemoveSessionMetadataKeyIfStamp( + ctx, "workflow-parking-session-pg", models.SessionMetaKeyWorkflowParking, second.Stamp, + ) + require.NoError(t, err) + require.True(t, removed, "matching parking cleanup did not remove the marker") +} diff --git a/apps/backend/internal/task/repository/sqlite/workflow_parking_test.go b/apps/backend/internal/task/repository/sqlite/workflow_parking_test.go new file mode 100644 index 0000000000..6354323774 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/workflow_parking_test.go @@ -0,0 +1,63 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/stretchr/testify/require" +) + +// TestWorkflowParking verifies that the durable parking marker uses the +// repository's stamped metadata operations, so a delayed cleanup cannot erase +// a newer workflow parking decision. +func TestWorkflowParking(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "workflow-parking-task", "workflow-parking-session", "workflow-parking-turn") + + first := models.WorkflowParking{ + Stamp: "parking-first", + ParkedAt: time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC), + WorkflowID: "workflow-1", + WorkflowStepID: "step-1", + RouteOperationID: "route-first", + EntryIdentity: "entry-first", + SourceSessionID: "workflow-parking-session", + DestinationID: "destination-first", + } + require.NoError(t, repo.SetSessionMetadataKey( + ctx, "workflow-parking-session", models.SessionMetaKeyWorkflowParking, first, + )) + + stored, err := repo.GetTaskSession(ctx, "workflow-parking-session") + require.NoError(t, err) + parking, ok := models.LoadWorkflowParking(stored.Metadata) + require.True(t, ok) + require.Equal(t, first, parking) + + second := first + second.Stamp = "parking-second" + second.RouteOperationID = "route-second" + require.NoError(t, repo.SetSessionMetadataKey( + ctx, "workflow-parking-session", models.SessionMetaKeyWorkflowParking, second, + )) + + removed, err := repo.RemoveSessionMetadataKeyIfStamp( + ctx, "workflow-parking-session", models.SessionMetaKeyWorkflowParking, first.Stamp, + ) + require.NoError(t, err) + require.False(t, removed, "stale parking cleanup removed the successor marker") + + removed, err = repo.RemoveSessionMetadataKeyIfStamp( + ctx, "workflow-parking-session", models.SessionMetaKeyWorkflowParking, second.Stamp, + ) + require.NoError(t, err) + require.True(t, removed, "matching parking cleanup did not remove the marker") + + stored, err = repo.GetTaskSession(ctx, "workflow-parking-session") + require.NoError(t, err) + _, ok = models.LoadWorkflowParking(stored.Metadata) + require.False(t, ok, "matching cleanup left workflow parking metadata behind") +} diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 76e402ef40..5e82cded75 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -415,6 +415,7 @@ type Service struct { taskLifecycleCoordinator TaskLifecycleCoordinator attachmentSvc *AttachmentService statusSummaryPRs TaskStatusSummaryPRReader + statusSummaryLaunchQueue TaskStatusSummaryLaunchQueueReader statusSummaryProjector TaskStatusSummaryEventProjector queuedPromptCounter QueuedPromptCounter eventBus bus.EventBus diff --git a/apps/backend/internal/task/service/service_deferred_launch.go b/apps/backend/internal/task/service/service_deferred_launch.go index 4f9b0f8cdf..69753dfab9 100644 --- a/apps/backend/internal/task/service/service_deferred_launch.go +++ b/apps/backend/internal/task/service/service_deferred_launch.go @@ -29,6 +29,35 @@ var ( ErrDeferredLaunchPromptEmpty = errors.New("deferred launch prompt must not be empty") ) +// ReadCeilingDeferredLaunch returns the authoritative ceiling record used by +// message admission and task-state reconciliation. An absent record is a +// valid negative result. A repository failure, or a record that explicitly +// claims the ceiling half but cannot be decoded, is returned so a caller +// never changes task state while queue ownership is uncertain. +func (s *Service) ReadCeilingDeferredLaunch( + ctx context.Context, taskID string, +) (models.CeilingDeferral, bool, error) { + if err := s.authorizeTaskID(ctx, taskID); err != nil { + return models.CeilingDeferral{}, false, err + } + record, _, err := s.tasks.GetTaskDeferredLaunch(ctx, taskID) + if err != nil { + return models.CeilingDeferral{}, false, err + } + if record == nil { + return models.CeilingDeferral{}, false, nil + } + ceilingFlag, hasCeilingFlag := record[models.CeilingDeferredKey] + if !hasCeilingFlag || ceilingFlag != true { + return models.CeilingDeferral{}, false, nil + } + deferral, err := models.ReadCeilingDeferral(record) + if err != nil { + return models.CeilingDeferral{}, false, err + } + return deferral, true, nil +} + // UpdateDeferredLaunchPrompt rewrites the prompt a task will be launched with // once its gate opens (dependencies resolve, or WIP capacity frees up). // diff --git a/apps/backend/internal/task/service/service_status_summary_launch_queue_test.go b/apps/backend/internal/task/service/service_status_summary_launch_queue_test.go new file mode 100644 index 0000000000..69073de87d --- /dev/null +++ b/apps/backend/internal/task/service/service_status_summary_launch_queue_test.go @@ -0,0 +1,49 @@ +package service + +import ( + "testing" + "time" + + "github.com/kandev/kandev/internal/task/statussummary" +) + +func TestStatusSummaryLaunchQueueEqualityIgnoresCapacityObservationTime(t *testing.T) { + queuedAt := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + left := &statussummary.LaunchQueueSummary{ + SessionID: "session-luna", QueuedAt: queuedAt, + Reason: statussummary.LaunchQueueReasonSessionCapacity, Retrying: true, + Capacity: &statussummary.LaunchQueueCapacity{InUse: 5, Limit: 5, ObservedAt: queuedAt}, + } + right := *left + right.Capacity = &statussummary.LaunchQueueCapacity{InUse: 5, Limit: 5, ObservedAt: queuedAt.Add(time.Minute)} + if !statussummaryLaunchQueueEqual(left, &right) { + t.Fatal("capacity observation timestamp alone must not require persistence") + } +} + +func TestOverlayLaunchQueueObservationRefreshesResponseWithoutMutatingCurrent(t *testing.T) { + queuedAt := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + oldObservedAt := queuedAt.Add(time.Minute) + newObservedAt := queuedAt.Add(2 * time.Minute) + current := &statussummary.TaskStatusSummary{ + Revision: 7, + LaunchQueue: &statussummary.LaunchQueueSummary{ + SessionID: "session-luna", QueuedAt: queuedAt, + Reason: statussummary.LaunchQueueReasonSessionCapacity, Retrying: true, + Capacity: &statussummary.LaunchQueueCapacity{InUse: 5, Limit: 5, ObservedAt: oldObservedAt}, + }, + } + observed := *current.LaunchQueue + observed.Capacity = &statussummary.LaunchQueueCapacity{InUse: 5, Limit: 5, ObservedAt: newObservedAt} + + got := overlayLaunchQueueObservation(current, &observed, true) + if got == current || got.LaunchQueue == current.LaunchQueue || got.LaunchQueue.Capacity == current.LaunchQueue.Capacity { + t.Fatal("observation overlay must return cloned summary data") + } + if got.Revision != current.Revision || !got.LaunchQueue.Capacity.ObservedAt.Equal(newObservedAt) { + t.Fatalf("overlay = %+v, want revision %d and observation %s", got, current.Revision, newObservedAt) + } + if !current.LaunchQueue.Capacity.ObservedAt.Equal(oldObservedAt) { + t.Fatal("observation overlay mutated the current summary") + } +} diff --git a/apps/backend/internal/task/service/service_status_summary_rebuild.go b/apps/backend/internal/task/service/service_status_summary_rebuild.go index fbdc70b5e4..22649037c3 100644 --- a/apps/backend/internal/task/service/service_status_summary_rebuild.go +++ b/apps/backend/internal/task/service/service_status_summary_rebuild.go @@ -26,6 +26,14 @@ type TaskStatusSummaryPRReader interface { ) (map[string][]statussummary.PullRequestInput, error) } +// TaskStatusSummaryLaunchQueueReader supplies the task-owned launch queue +// projection and may refresh shared admission observations once for a batch. +// The task service does not depend on the orchestrator's controller type. +type TaskStatusSummaryLaunchQueueReader func( + context.Context, + []*models.Task, +) map[string]*statussummary.LaunchQueueSummary + // SetTaskStatusSummaryPRReader wires the optional provider-backed PR reader. // The task service keeps the interface narrow so it does not depend on the // GitHub package. @@ -35,6 +43,14 @@ func (s *Service) SetTaskStatusSummaryPRReader(reader TaskStatusSummaryPRReader) } } +// SetTaskStatusSummaryLaunchQueueReader wires the optional live admission +// observation used when boot and task-list summaries are rebuilt. +func (s *Service) SetTaskStatusSummaryLaunchQueueReader(reader TaskStatusSummaryLaunchQueueReader) { + if s != nil { + s.statusSummaryLaunchQueue = reader + } +} + // ReconcileTaskStatusSummaries repairs stale pending state in existing rows and // builds absent rows. All durable inputs are batch-loaded by the caller or by // optional batch readers, so startup does not scan every historical task. A @@ -54,8 +70,9 @@ func (s *Service) ReconcileTaskStatusSummaries( return summaries, nil } activityByTask, activityObserved := s.loadSummaryActivity(ctx, taskIDs(tasks)) + launchQueueByTask := s.launchQueueSummaries(ctx, tasks) failedTaskIDs, reconcileErr := s.reconcileExistingSummaries( - ctx, tasks, sessionsByTask, pendingBySession, summaries, activityByTask, activityObserved, + ctx, tasks, sessionsByTask, pendingBySession, summaries, activityByTask, activityObserved, launchQueueByTask, ) rebuildTasks := tasks if len(failedTaskIDs) > 0 { @@ -70,7 +87,7 @@ func (s *Service) ReconcileTaskStatusSummaries( } } summaries = s.rebuildMissingSummaries( - ctx, rebuildTasks, sessionsByTask, pendingBySession, summaries, activityByTask, activityObserved, + ctx, rebuildTasks, sessionsByTask, pendingBySession, summaries, activityByTask, activityObserved, launchQueueByTask, ) return summaries, reconcileErr } @@ -83,6 +100,7 @@ func (s *Service) reconcileExistingSummaries( summaries map[string]*statussummary.TaskStatusSummary, activityByTask map[string]time.Time, activityObserved bool, + launchQueueByTask map[string]*statussummary.LaunchQueueSummary, ) (map[string]struct{}, error) { var reconcileErr error failedTaskIDs := make(map[string]struct{}) @@ -99,6 +117,7 @@ func (s *Service) reconcileExistingSummaries( action, activityByTask[task.ID], activityObserved, + launchQueueByTask[task.ID], ) if err != nil { if reconciled != nil { @@ -134,6 +153,7 @@ func (s *Service) rebuildMissingSummaries( summaries map[string]*statussummary.TaskStatusSummary, activityByTask map[string]time.Time, activityObserved bool, + launchQueueByTask map[string]*statussummary.LaunchQueueSummary, ) map[string]*statussummary.TaskStatusSummary { missing := missingSummaryTasks(tasks, summaries) if len(missing) == 0 { @@ -157,6 +177,7 @@ func (s *Service) rebuildMissingSummaries( prByTask[task.ID], prObserved, queuedByTask[task.ID], + launchQueueByTask[task.ID], activityAt, activityObserved, now, @@ -190,6 +211,55 @@ func (s *Service) loadSummaryActivity(ctx context.Context, taskIDs []string) (ma return activityByTask, true } +func (s *Service) launchQueueSummaries( + ctx context.Context, + tasks []*models.Task, +) map[string]*statussummary.LaunchQueueSummary { + if s.statusSummaryLaunchQueue != nil { + if queues := s.statusSummaryLaunchQueue(ctx, tasks); queues != nil { + return queues + } + } + queues := make(map[string]*statussummary.LaunchQueueSummary, len(tasks)) + for _, task := range tasks { + if task == nil || task.ID == "" { + continue + } + queues[task.ID] = statussummary.LaunchQueueSummaryFromTask(task) + } + return queues +} + +func cloneLaunchQueueForService(queue *statussummary.LaunchQueueSummary) *statussummary.LaunchQueueSummary { + if queue == nil { + return nil + } + copy := *queue + if queue.Capacity != nil { + capacity := *queue.Capacity + copy.Capacity = &capacity + } + return © +} + +func statussummaryLaunchQueueEqual(left, right *statussummary.LaunchQueueSummary) bool { + if left == nil || right == nil { + return left == right + } + if left.SessionID != right.SessionID || left.AgentProfileID != right.AgentProfileID || + left.WorkflowStepID != right.WorkflowStepID || !left.QueuedAt.Equal(right.QueuedAt) || + left.Reason != right.Reason || left.Retrying != right.Retrying { + return false + } + if left.Capacity == nil || right.Capacity == nil { + return left.Capacity == right.Capacity + } + // The sample time is an in-memory freshness overlay. Persist only changes + // to the queue's identity or capacity values. + return left.Capacity.InUse == right.Capacity.InUse && + left.Capacity.Limit == right.Capacity.Limit +} + func (s *Service) rebuildMissingSummary( ctx context.Context, task *models.Task, @@ -241,10 +311,19 @@ func (s *Service) reconcileExistingSummary( pendingAction string, authoritativeActivity time.Time, activityObserved bool, + launchQueueValues ...*statussummary.LaunchQueueSummary, ) (*statussummary.TaskStatusSummary, error) { + var launchQueue *statussummary.LaunchQueueSummary + // Older repair tests and partial task snapshots can omit Metadata entirely. + // In that shape there is no authoritative queue to compare, unless the + // persisted summary still advertises one that must be cleared. + launchQueueObserved := len(launchQueueValues) > 0 && (task.Metadata != nil || current.LaunchQueue != nil) + if launchQueueObserved { + launchQueue = launchQueueValues[0] + } for attempt := 0; attempt < maxSummaryReconcileAttempts && current != nil; attempt++ { - if !summaryNeedsReconcile(current, pendingAction, authoritativeActivity, activityObserved) { - return current, nil + if !summaryNeedsReconcile(current, pendingAction, authoritativeActivity, activityObserved, launchQueue, launchQueueObserved) { + return overlayLaunchQueueObservation(current, launchQueue, launchQueueObserved), nil } if err := prepareSummaryReconcileAttempt(ctx, attempt, current.Revision); err != nil { return nil, err @@ -254,6 +333,7 @@ func (s *Service) reconcileExistingSummary( if activityObserved && authoritativeActivity.After(time.Time{}) { next.LastActivityAt = maxSummaryActivity(current.LastActivityAt, authoritativeActivity) } + next.LaunchQueue = cloneLaunchQueueForService(launchQueue) next.Revision = current.Revision + 1 next.UpdatedAt = advancedSummaryTime(current.UpdatedAt, time.Now().UTC()) if err := next.Validate(); err != nil { @@ -271,7 +351,7 @@ func (s *Service) reconcileExistingSummary( s.publishReconciledSummary(ctx, task, next) return &next, nil } - current, pendingAction, err = s.reloadSummaryReconcileState(ctx, task.ID) + current, pendingAction, launchQueue, err = s.reloadSummaryReconcileState(ctx, task.ID, launchQueueObserved) if err != nil { return nil, err } @@ -279,18 +359,42 @@ func (s *Service) reconcileExistingSummary( return nil, nil } } - if !summaryNeedsReconcile(current, pendingAction, authoritativeActivity, activityObserved) { - return current, nil + if !summaryNeedsReconcile(current, pendingAction, authoritativeActivity, activityObserved, launchQueue, launchQueueObserved) { + return overlayLaunchQueueObservation(current, launchQueue, launchQueueObserved), nil } s.logSummaryReconcileExhaustion(task.ID, current) return nil, errors.New("exhausted compare-and-set retries") } +// overlayLaunchQueueObservation returns a response-only copy when the live +// queue has only a newer capacity sample. Observation time is intentionally +// excluded from the durable equality contract, but task-list consumers still +// need the fresh timestamp to render age and connectivity state accurately. +func overlayLaunchQueueObservation( + current *statussummary.TaskStatusSummary, + observed *statussummary.LaunchQueueSummary, + observedEnabled bool, +) *statussummary.TaskStatusSummary { + if !observedEnabled || current == nil || observed == nil || current.LaunchQueue == nil || + !statussummaryLaunchQueueEqual(current.LaunchQueue, observed) { + return current + } + if current.LaunchQueue.Capacity == nil || observed.Capacity == nil || + current.LaunchQueue.Capacity.ObservedAt.Equal(observed.Capacity.ObservedAt) { + return current + } + copy := *current + copy.LaunchQueue = cloneLaunchQueueForService(observed) + return © +} + func summaryNeedsReconcile( current *statussummary.TaskStatusSummary, pendingAction string, authoritativeActivity time.Time, activityObserved bool, + launchQueue *statussummary.LaunchQueueSummary, + launchQueueObserved bool, ) bool { if current == nil { return false @@ -298,6 +402,9 @@ func summaryNeedsReconcile( if current.PendingAction != pendingAction { return true } + if launchQueueObserved && !statussummaryLaunchQueueEqual(current.LaunchQueue, launchQueue) { + return true + } return activityObserved && authoritativeActivity.After(time.Time{}) && (current.LastActivityAt == nil || authoritativeActivity.After(*current.LastActivityAt)) } @@ -305,27 +412,36 @@ func summaryNeedsReconcile( func (s *Service) reloadSummaryReconcileState( ctx context.Context, taskID string, -) (*statussummary.TaskStatusSummary, string, error) { + loadLaunchQueue bool, +) (*statussummary.TaskStatusSummary, string, *statussummary.LaunchQueueSummary, error) { rows, err := s.statusSummaries.LoadTaskStatusSummaries(ctx, []string{taskID}) if err != nil { - return nil, "", fmt.Errorf("reload after compare-and-set rejection: %w", err) + return nil, "", nil, fmt.Errorf("reload after compare-and-set rejection: %w", err) } current := rows[taskID] if current == nil { - return nil, "", nil + return nil, "", nil, nil } if s.sessions == nil { - return nil, "", errors.New("reload sessions: session repository unavailable") + return nil, "", nil, errors.New("reload sessions: session repository unavailable") } refreshedSessions, err := s.sessions.ListTaskSessions(ctx, taskID) if err != nil { - return nil, "", fmt.Errorf("reload sessions: %w", err) + return nil, "", nil, fmt.Errorf("reload sessions: %w", err) } pendingBySession, err := s.GetPendingActionsForSessions(ctx, taskSessionIDs(refreshedSessions)) if err != nil { - return nil, "", fmt.Errorf("reload pending actions: %w", err) + return nil, "", nil, fmt.Errorf("reload pending actions: %w", err) } - return current, pendingActionForTask(refreshedSessions, pendingBySession), nil + if !loadLaunchQueue { + return current, pendingActionForTask(refreshedSessions, pendingBySession), nil, nil + } + task, err := s.GetTask(ctx, taskID) + if err != nil { + return nil, "", nil, fmt.Errorf("reload task for launch queue: %w", err) + } + queues := s.launchQueueSummaries(ctx, []*models.Task{task}) + return current, pendingActionForTask(refreshedSessions, pendingBySession), queues[taskID], nil } func maxSummaryActivity(current *time.Time, candidate time.Time) *time.Time { @@ -567,6 +683,7 @@ func (s *Service) rebuildInput( prs []statussummary.PullRequestInput, prObserved bool, queuedPromptCount int, + launchQueue *statussummary.LaunchQueueSummary, lastActivityAt time.Time, activityObserved bool, now time.Time, @@ -581,6 +698,7 @@ func (s *Service) rebuildInput( PRObserved: prObserved, GitObserved: gitObserved, QueuedPromptCount: maxInt(queuedPromptCount, 0), + LaunchQueue: cloneLaunchQueueForService(launchQueue), Now: now, } if activityObserved && !lastActivityAt.IsZero() { diff --git a/apps/backend/internal/task/statussummary/launch_queue.go b/apps/backend/internal/task/statussummary/launch_queue.go new file mode 100644 index 0000000000..7c06e7b6bf --- /dev/null +++ b/apps/backend/internal/task/statussummary/launch_queue.go @@ -0,0 +1,180 @@ +package statussummary + +import ( + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +// LaunchQueueCapacityObservation is a point-in-time admission reading supplied +// by the orchestrator. Known is false when the controller or its population +// store cannot be read; the queue remains visible but its count is omitted. +type LaunchQueueCapacityObservation struct { + InUse int + Limit int + ObservedAt time.Time + Known bool +} + +// LaunchQueueSummaryFromTask maps the durable automatic-launch record to the +// bounded task-list projection. The launch payload remains private to the +// orchestrator and is never copied into the status summary. +func LaunchQueueSummaryFromTask(task *models.Task) *LaunchQueueSummary { + return launchQueueSummaryFromTask(task, nil) +} + +// LaunchQueueSummaryFromTaskWithCapacity maps a durable queue entry while +// replacing its old refusal-time capacity with the latest controller reading. +// The durable record remains the source of queue identity and time. +func LaunchQueueSummaryFromTaskWithCapacity( + task *models.Task, + observation *LaunchQueueCapacityObservation, +) *LaunchQueueSummary { + return launchQueueSummaryFromTask(task, observation) +} + +func launchQueueSummaryFromTask( + task *models.Task, + observation *LaunchQueueCapacityObservation, +) *LaunchQueueSummary { + if !models.HasCeilingDeferredIntent(task) { + return nil + } + + record, _ := task.Metadata[models.MetaKeyDeferredLaunch].(map[string]interface{}) + deferral, err := models.ReadCeilingDeferral(record) + if err != nil { + return launchQueueReplayErrorSummary(task) + } + + queuedAt := launchQueueQueuedAt(task, deferral) + destinationID := models.CeilingDeferralSessionID(task, deferral) + queue := &LaunchQueueSummary{ + SessionID: destinationID, + AgentProfileID: launchQueueStringField(deferral.Payload, "agent_profile_id"), + WorkflowStepID: launchQueueStringField(deferral.Payload, "workflow_step_id"), + QueuedAt: queuedAt, + Reason: LaunchQueueReasonSessionCapacity, + Retrying: true, + } + if binding, present, bindingErr := models.ReadCeilingWorkflowEntryBinding(deferral.Payload); bindingErr == nil && present && queue.WorkflowStepID == "" { + queue.WorkflowStepID = binding.DestinationStepID + } + // Workflow-origin records are bound to the task's committed route. A + // malformed binding or a binding that no longer matches that route is not a + // capacity wait: showing it as retryable would advertise work that replay + // must not dispatch. Keep the row visible with an actionable ownership state + // while the authoritative lifecycle path disposes or replaces the record. + if launchQueueOwnershipUnavailable(task, deferral, queue.WorkflowStepID, destinationID) { + queue.Reason = LaunchQueueReasonOwnershipUnavailable + queue.Retrying = false + return queue + } + if queue.WorkflowStepID == "" { + if route, ok := models.LoadWorkflowSessionRoute(task.Metadata); ok { + queue.WorkflowStepID = route.DestinationStepID + } + } + queue.Capacity = launchQueueCapacity(deferral, observation, queuedAt) + return queue +} + +func launchQueueReplayErrorSummary(task *models.Task) *LaunchQueueSummary { + queuedAt := task.UpdatedAt.UTC() + if queuedAt.IsZero() { + queuedAt = time.Now().UTC() + } + return &LaunchQueueSummary{QueuedAt: queuedAt, Reason: LaunchQueueReasonReplayError, Retrying: true} +} + +func launchQueueQueuedAt(task *models.Task, deferral models.CeilingDeferral) time.Time { + queuedAt := deferral.QueuedAt.UTC() + if queuedAt.IsZero() { + queuedAt = task.UpdatedAt.UTC() + } + if queuedAt.IsZero() { + queuedAt = time.Now().UTC() + } + return queuedAt +} + +func launchQueueOwnershipUnavailable( + task *models.Task, + deferral models.CeilingDeferral, + workflowStepID, destinationID string, +) bool { + binding, bindingPresent, bindingErr := models.ReadCeilingWorkflowEntryBinding(deferral.Payload) + workflowOrigin := bindingPresent || workflowStepID != "" || + launchQueueInt64Field(deferral.Payload, "workflow_entry_id") > 0 + if deferral.Kind == models.CeilingLaunchStart { + if _, routePresent := models.LoadWorkflowSessionRoute(task.Metadata); routePresent { + workflowOrigin = true + } + } + if bindingErr != nil { + return true + } + if !workflowOrigin { + return false + } + if destinationID == "" { + return false + } + if !bindingPresent || models.CeilingDeferralTargetsSession(task, deferral, destinationID) { + return false + } + // Direct-profile workflow entries have no workflow_session_route. Their + // binding is still useful to the projection because the task's current + // workflow and destination step identify the selected lane. Replay applies + // the stricter latest-entry-ledger check before dispatch. + if _, routePresent := models.LoadWorkflowSessionRoute(task.Metadata); !routePresent && + binding.WorkflowID != "" && task.WorkflowID == binding.WorkflowID && + task.WorkflowStepID == binding.DestinationStepID && + binding.DestinationSessionID == destinationID { + return false + } + return true +} + +func launchQueueCapacity( + deferral models.CeilingDeferral, + observation *LaunchQueueCapacityObservation, + queuedAt time.Time, +) *LaunchQueueCapacity { + if observation != nil { + if observation.Known && observation.InUse >= 0 && observation.Limit >= 0 && !observation.ObservedAt.IsZero() { + return &LaunchQueueCapacity{ + InUse: observation.InUse, + Limit: observation.Limit, + ObservedAt: observation.ObservedAt.UTC(), + } + } + return nil + } + if !deferral.PopulationKnown && deferral.Ceiling <= 0 { + return nil + } + return &LaunchQueueCapacity{ + InUse: deferral.Population, + Limit: deferral.Ceiling, + ObservedAt: queuedAt, + } +} + +func launchQueueStringField(payload map[string]interface{}, key string) string { + value, _ := payload[key].(string) + return value +} + +func launchQueueInt64Field(payload map[string]interface{}, key string) int64 { + switch value := payload[key].(type) { + case float64: + return int64(value) + case int64: + return value + case int: + return int64(value) + default: + return 0 + } +} diff --git a/apps/backend/internal/task/statussummary/model.go b/apps/backend/internal/task/statussummary/model.go index 4eafd810b1..378312ba19 100644 --- a/apps/backend/internal/task/statussummary/model.go +++ b/apps/backend/internal/task/statussummary/model.go @@ -31,6 +31,14 @@ const ( maxActiveErrorCategoryBytes = 64 maxPullRequestStateBytes = 64 maxPullRequestURLBytes = 2048 + maxLaunchQueueIDBytes = 256 + maxLaunchQueueReasonBytes = 64 +) + +const ( + LaunchQueueReasonSessionCapacity = "session_capacity" + LaunchQueueReasonOwnershipUnavailable = "ownership_unavailable" + LaunchQueueReasonReplayError = "replay_error" ) // TaskStatusSummary is the complete replacement value delivered to task-list @@ -53,6 +61,25 @@ type TaskStatusSummary struct { // message.queue.get). Omitted when zero so task rows without queued work // stay byte-identical to earlier builds. QueuedPromptCount int `json:"queued_prompt_count,omitempty"` + // LaunchQueue is the live task-level projection for automatic session work + // waiting on admission. It is independent of the selected transcript. + LaunchQueue *LaunchQueueSummary `json:"launch_queue,omitempty"` +} + +type LaunchQueueSummary struct { + SessionID string `json:"session_id,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + WorkflowStepID string `json:"workflow_step_id,omitempty"` + QueuedAt time.Time `json:"queued_at"` + Reason string `json:"reason"` + Retrying bool `json:"retrying"` + Capacity *LaunchQueueCapacity `json:"capacity,omitempty"` +} + +type LaunchQueueCapacity struct { + InUse int `json:"in_use"` + Limit int `json:"limit"` + ObservedAt time.Time `json:"observed_at"` } type PrimarySessionSummary struct { @@ -141,7 +168,10 @@ func (s TaskStatusSummary) Validate() error { if err := validateActiveError(s.TaskError); err != nil { return err } - return validatePullRequest(s.PullRequest) + if err := validatePullRequest(s.PullRequest); err != nil { + return err + } + return validateLaunchQueue(s.LaunchQueue) } func validatePrimarySession(session *PrimarySessionSummary) error { @@ -224,6 +254,44 @@ func validatePullRequest(pr *PullRequestSummary) error { return nil } +func validateLaunchQueue(queue *LaunchQueueSummary) error { + if queue == nil { + return nil + } + for _, field := range []struct { + name string + value string + limit int + }{ + {"launch queue session id", queue.SessionID, maxLaunchQueueIDBytes}, + {"launch queue agent profile id", queue.AgentProfileID, maxLaunchQueueIDBytes}, + {"launch queue workflow step id", queue.WorkflowStepID, maxLaunchQueueIDBytes}, + {"launch queue reason", queue.Reason, maxLaunchQueueReasonBytes}, + } { + if err := validateUTF8Bytes(field.name, field.value, field.limit); err != nil { + return err + } + } + if queue.QueuedAt.IsZero() { + return fmt.Errorf("launch queue queued_at is required") + } + if queue.Reason != LaunchQueueReasonSessionCapacity && + queue.Reason != LaunchQueueReasonOwnershipUnavailable && + queue.Reason != LaunchQueueReasonReplayError { + return fmt.Errorf("launch queue has unknown reason") + } + if queue.Capacity == nil { + return nil + } + if queue.Capacity.InUse < 0 || queue.Capacity.Limit < 0 { + return fmt.Errorf("launch queue capacity cannot be negative") + } + if queue.Capacity.ObservedAt.IsZero() { + return fmt.Errorf("launch queue capacity observed_at is required") + } + return nil +} + func validateUTF8Bytes(field, value string, maxBytes int) error { if !utf8.ValidString(value) { return fmt.Errorf("%s must be valid UTF-8", field) @@ -252,6 +320,7 @@ func (s TaskStatusSummary) SemanticJSON() ([]byte, error) { Git: s.Git, PullRequest: s.PullRequest, QueuedPromptCount: s.QueuedPromptCount, + LaunchQueue: s.LaunchQueue, }) } @@ -266,4 +335,5 @@ type semanticPayload struct { Git *GitSummary `json:"git,omitempty"` PullRequest *PullRequestSummary `json:"pull_request,omitempty"` QueuedPromptCount int `json:"queued_prompt_count,omitempty"` + LaunchQueue *LaunchQueueSummary `json:"launch_queue,omitempty"` } diff --git a/apps/backend/internal/task/statussummary/model_test.go b/apps/backend/internal/task/statussummary/model_test.go index b076773b0e..2819a46515 100644 --- a/apps/backend/internal/task/statussummary/model_test.go +++ b/apps/backend/internal/task/statussummary/model_test.go @@ -129,6 +129,214 @@ func TestTaskStatusSummaryQueuedPromptCountAffectsSemanticEquality(t *testing.T) } } +func TestTaskStatusSummaryLaunchQueueRoundTripsAndIsSemantic(t *testing.T) { + queuedAt := time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC) + summary := TaskStatusSummary{ + LaunchQueue: &LaunchQueueSummary{ + SessionID: "session-luna", + AgentProfileID: "profile-luna", + WorkflowStepID: "step-implement", + QueuedAt: queuedAt, + Reason: LaunchQueueReasonSessionCapacity, + Retrying: true, + Capacity: &LaunchQueueCapacity{ + InUse: 5, + Limit: 5, + ObservedAt: queuedAt, + }, + }, + } + payload, err := summary.SemanticJSON() + if err != nil { + t.Fatalf("semantic JSON: %v", err) + } + var decoded TaskStatusSummary + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("decode semantic JSON: %v", err) + } + if !summary.SemanticEqual(decoded) { + t.Fatalf("launch queue changed after semantic round trip: %#v", decoded.LaunchQueue) + } + + changed := summary + changed.LaunchQueue = &LaunchQueueSummary{SessionID: "session-other", QueuedAt: queuedAt, Reason: LaunchQueueReasonSessionCapacity} + if summary.SemanticEqual(changed) { + t.Fatal("launch queue destination must affect semantic equality") + } +} + +func TestTaskStatusSummaryLaunchQueueValidationRejectsUnknownOrUnboundedValues(t *testing.T) { + queuedAt := time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC) + valid := TaskStatusSummary{LaunchQueue: &LaunchQueueSummary{ + SessionID: "session-luna", QueuedAt: queuedAt, Reason: LaunchQueueReasonSessionCapacity, + Capacity: &LaunchQueueCapacity{InUse: 1, Limit: 2, ObservedAt: queuedAt}, + }} + if err := valid.Validate(); err != nil { + t.Fatalf("valid launch queue rejected: %v", err) + } + unknownReason := valid + unknownReason.LaunchQueue = &LaunchQueueSummary{SessionID: "session-luna", QueuedAt: queuedAt, Reason: "invented"} + if err := unknownReason.Validate(); err == nil { + t.Fatal("unknown launch queue reason accepted") + } + invalidCapacity := valid + invalidCapacity.LaunchQueue = &LaunchQueueSummary{ + SessionID: "session-luna", QueuedAt: queuedAt, Reason: LaunchQueueReasonSessionCapacity, + Capacity: &LaunchQueueCapacity{InUse: -1, Limit: 2, ObservedAt: queuedAt}, + } + if err := invalidCapacity.Validate(); err == nil { + t.Fatal("capacity over limit accepted") + } +} + +func TestLaunchQueueSummaryFromTaskUsesDurableCeilingDeferral(t *testing.T) { + queuedAt := time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC) + task := &models.Task{Metadata: map[string]interface{}{models.MetaKeyDeferredLaunch: models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + "session_id": "session-luna", + "agent_profile_id": "profile-luna", + "workflow_step_id": "step-implement", + }, + ReasonCode: "ceiling", + QueuedAt: queuedAt, + Population: 5, + PopulationKnown: true, + Ceiling: 5, + })}} + got := LaunchQueueSummaryFromTask(task) + if got == nil || got.SessionID != "session-luna" || got.AgentProfileID != "profile-luna" || + got.WorkflowStepID != "step-implement" || !got.QueuedAt.Equal(queuedAt) || + got.Reason != LaunchQueueReasonSessionCapacity || !got.Retrying { + t.Fatalf("launch queue = %+v", got) + } + if got.Capacity == nil || got.Capacity.InUse != 5 || got.Capacity.Limit != 5 { + t.Fatalf("launch queue capacity = %+v", got.Capacity) + } +} + +func TestLaunchQueueSummaryFromTaskAllowsDirectProfileWorkflowBindingWithoutRoute(t *testing.T) { + queuedAt := time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC) + task := &models.Task{ + WorkflowID: "workflow-1", + WorkflowStepID: "step-implement", + Metadata: map[string]interface{}{models.MetaKeyDeferredLaunch: models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + "agent_profile_id": "profile-luna", + models.CeilingLaunchEntryBindingKey: map[string]interface{}{ + "workflow_id": "workflow-1", + "destination_step_id": "step-implement", + "route_operation_id": "direct-route", + "entry_identity": "entry:00000000000000000007", + "destination_session_id": "session-luna", + }, + }, + Origin: "automatic", + ReasonCode: "ceiling", + QueuedAt: queuedAt, + Population: 1, + Ceiling: 1, + })}, + } + + got := LaunchQueueSummaryFromTask(task) + if got == nil || got.SessionID != "session-luna" || + got.AgentProfileID != "profile-luna" || got.WorkflowStepID != "step-implement" || + got.Reason != LaunchQueueReasonSessionCapacity || !got.Retrying { + t.Fatalf("direct-profile launch queue = %+v, want a retryable capacity entry", got) + } +} + +func TestLaunchQueueSummaryFromTaskWithCapacityUsesLatestObservation(t *testing.T) { + queuedAt := time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC) + task := &models.Task{Metadata: map[string]interface{}{models.MetaKeyDeferredLaunch: models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + "session_id": "session-luna", + "agent_profile_id": "profile-luna", + }, + QueuedAt: queuedAt, + Population: 5, + PopulationKnown: true, + Ceiling: 5, + })}} + observedAt := queuedAt.Add(30 * time.Second) + got := LaunchQueueSummaryFromTaskWithCapacity(task, &LaunchQueueCapacityObservation{ + InUse: 3, Limit: 5, ObservedAt: observedAt, Known: true, + }) + if got == nil || got.Capacity == nil { + t.Fatalf("launch queue capacity = %+v, want latest observation", got) + } + if got.Capacity.InUse != 3 || got.Capacity.Limit != 5 || !got.Capacity.ObservedAt.Equal(observedAt) { + t.Fatalf("launch queue capacity = %+v, want 3/5 at %v", got.Capacity, observedAt) + } + if !got.QueuedAt.Equal(queuedAt) { + t.Fatalf("queue time changed with capacity observation: %v", got.QueuedAt) + } + + unknown := LaunchQueueSummaryFromTaskWithCapacity(task, &LaunchQueueCapacityObservation{ + Limit: 5, ObservedAt: observedAt, + }) + if unknown == nil || unknown.Capacity != nil { + t.Fatalf("unknown capacity = %+v, want nil while retaining queue", unknown) + } +} + +func TestLaunchQueueSummaryFromTaskResolvesSeamOneDestinationFromRoute(t *testing.T) { + queuedAt := time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC) + task := &models.Task{ + WorkflowID: "workflow-1", + WorkflowStepID: "step-implement", + Metadata: map[string]interface{}{ + models.MetaKeyWorkflowSessionRoute: models.WorkflowSessionRoute{ + OperationID: "route-1", + DestinationStepID: "step-implement", + EntryIdentity: "entry:1", + TargetKind: "new_session", + DestinationID: "session-luna", + Phase: "committed", + }, + models.MetaKeyDeferredLaunch: models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStart, + Payload: map[string]interface{}{ + "workflow_step_id": "step-implement", + }, + Origin: "automatic", ReasonCode: "session_capacity", QueuedAt: queuedAt, + }), + }, + } + got := LaunchQueueSummaryFromTask(task) + if got == nil || got.SessionID != "session-luna" { + t.Fatalf("seam-one launch queue = %+v, want route destination session-luna", got) + } +} + +func TestLaunchQueueSummaryFromTaskMarksInvalidWorkflowOwnership(t *testing.T) { + queuedAt := time.Date(2026, 9, 16, 20, 0, 0, 0, time.UTC) + task := &models.Task{Metadata: map[string]interface{}{ + models.MetaKeyDeferredLaunch: models.CeilingRecordKeys(models.CeilingDeferral{ + Kind: models.CeilingLaunchStartCreated, + Payload: map[string]interface{}{ + "session_id": "session-luna", + "workflow_step_id": "step-implement", + models.CeilingLaunchEntryBindingKey: map[string]interface{}{ + "workflow_id": "workflow-1", + "destination_step_id": "step-implement", + "route_operation_id": "route-old", + "entry_identity": "entry:old", + "destination_session_id": "session-luna", + }, + }, + Origin: "automatic", ReasonCode: "session_capacity", QueuedAt: queuedAt, + }), + }} + got := LaunchQueueSummaryFromTask(task) + if got == nil || got.Reason != LaunchQueueReasonOwnershipUnavailable || got.Retrying { + t.Fatalf("invalid workflow queue = %+v, want ownership_unavailable without retry", got) + } +} + func TestTaskStatusSummaryValidateBoundsErrorPreview(t *testing.T) { valid := TaskStatusSummary{ActiveError: &ActiveErrorSummary{Preview: strings.Repeat("é", MaxActiveErrorPreviewBytes/2)}} if err := valid.Validate(); err != nil { diff --git a/apps/backend/internal/task/statussummary/projector.go b/apps/backend/internal/task/statussummary/projector.go index 5f8b03f7d8..8b2e94b8ca 100644 --- a/apps/backend/internal/task/statussummary/projector.go +++ b/apps/backend/internal/task/statussummary/projector.go @@ -73,6 +73,10 @@ type TaskActivityLoader func(context.Context, string) (*time.Time, error) // sibling pull requests across projector restarts and CAS rebases. type PullRequestLoader func(context.Context, string) ([]PullRequestInput, error) +// LaunchQueueLoader reads the task-owned automatic launch queue. The loader +// returns nil when the task has no deferred launch. +type LaunchQueueLoader func(context.Context, string) (*LaunchQueueSummary, error) + // SummaryUpdated is the complete replacement payload sent to workspace // subscribers. It intentionally contains no transcript, file list, or source // event payload. @@ -96,6 +100,7 @@ type ProjectorConfig struct { LoadTaskLaunchError TaskLaunchErrorLoader LoadTaskActivity TaskActivityLoader LoadPullRequests PullRequestLoader + LoadLaunchQueue LaunchQueueLoader // CountQueuedPrompts returns the number of prompts currently en-queued for // a task across all of its sessions (pending semantics identical to // message.queue.get). Wired from the messagequeue service at the @@ -119,6 +124,7 @@ type Projector struct { loadTaskLaunchError TaskLaunchErrorLoader loadTaskActivity TaskActivityLoader loadPullRequests PullRequestLoader + loadLaunchQueue LaunchQueueLoader countQueuedPrompts func(context.Context, string) (int, error) logger *logger.Logger now func() time.Time @@ -153,14 +159,16 @@ type projectionState struct { // clearedErrorStamps records, per session, the stamp of the last error this // projection cleared, so a durable breadcrumb replayed on a later session // event cannot re-arm an error affordance the agent already recovered from. - clearedErrorStamps map[string]string - errorsObserved bool - git map[string]GitSummary - gitBaseline *GitSummary - gitObserved bool - prs map[string]pullRequestObservation - prBaseline *PullRequestSummary - prObserved bool + clearedErrorStamps map[string]string + errorsObserved bool + git map[string]GitSummary + gitBaseline *GitSummary + gitObserved bool + prs map[string]pullRequestObservation + prBaseline *PullRequestSummary + prObserved bool + launchQueue *LaunchQueueSummary + launchQueueObserved bool } type sessionObservation struct { @@ -212,6 +220,7 @@ func NewProjector(cfg ProjectorConfig) *Projector { loadTaskLaunchError: cfg.LoadTaskLaunchError, loadTaskActivity: cfg.LoadTaskActivity, loadPullRequests: cfg.LoadPullRequests, + loadLaunchQueue: cfg.LoadLaunchQueue, countQueuedPrompts: cfg.CountQueuedPrompts, logger: log.WithFields(zap.String("component", "task-status-summary-projector")), now: now, @@ -349,6 +358,16 @@ func (p *Projector) handleEvent(ctx context.Context, event *bus.Event) error { return err } } + launchQueueChanged := false + if p.loadLaunchQueue != nil { + nextQueue, loadErr := p.loadLaunchQueue(ctx, taskID) + if loadErr != nil { + return fmt.Errorf("load launch queue for task status summary %q: %w", taskID, loadErr) + } + launchQueueChanged = !state.launchQueueObserved || !equalLaunchQueue(state.launchQueue, nextQueue) + state.launchQueue = cloneLaunchQueue(nextQueue) + state.launchQueueObserved = true + } if event.Type == events.MessageQueueStatusChanged { activityChanged := applyTaskActivityEventLocked(state, event.Type, data) @@ -360,7 +379,7 @@ func (p *Projector) handleEvent(ctx context.Context, event *bus.Event) error { return refreshErr } } - return p.applyQueueStatusEvent(ctx, state, taskID, pendingChanged || activityChanged || taskErrorChanged, event.Type, data) + return p.applyQueueStatusEvent(ctx, state, taskID, pendingChanged || activityChanged || taskErrorChanged || launchQueueChanged, event.Type, data) } refreshPending := p.loadPendingActions != nil && @@ -370,11 +389,11 @@ func (p *Projector) handleEvent(ctx context.Context, event *bus.Event) error { if refreshErr != nil { return refreshErr } - changed := p.applySourceEventLocked(state, event.Type, data) || pendingChanged || taskErrorChanged || pullRequestChanged + changed := p.applySourceEventLocked(state, event.Type, data) || pendingChanged || taskErrorChanged || pullRequestChanged || launchQueueChanged return p.persistPendingRefreshLocked(ctx, taskID, state, changed, event.Type, data) } - changed := p.applySourceEventLocked(state, event.Type, data) || taskErrorChanged || pullRequestChanged + changed := p.applySourceEventLocked(state, event.Type, data) || taskErrorChanged || pullRequestChanged || launchQueueChanged if !changed { return nil } diff --git a/apps/backend/internal/task/statussummary/projector_derive.go b/apps/backend/internal/task/statussummary/projector_derive.go index 2aabda30ff..142a8c30ec 100644 --- a/apps/backend/internal/task/statussummary/projector_derive.go +++ b/apps/backend/internal/task/statussummary/projector_derive.go @@ -19,9 +19,44 @@ func deriveSummary(state *projectionState) TaskStatusSummary { PullRequest: derivePullRequestSummary(state), QueuedPromptCount: state.queuedCount, LastActivityAt: cloneTimePtr(state.lastActivityAt), + LaunchQueue: cloneLaunchQueue(state.launchQueue), } } +func cloneLaunchQueue(queue *LaunchQueueSummary) *LaunchQueueSummary { + if queue == nil { + return nil + } + copy := *queue + if queue.Capacity != nil { + capacity := *queue.Capacity + copy.Capacity = &capacity + } + return © +} + +func equalLaunchQueue(left, right *LaunchQueueSummary) bool { + if left == nil || right == nil { + return left == right + } + if left.SessionID != right.SessionID || + left.AgentProfileID != right.AgentProfileID || + left.WorkflowStepID != right.WorkflowStepID || + !left.QueuedAt.Equal(right.QueuedAt) || + left.Reason != right.Reason || + left.Retrying != right.Retrying { + return false + } + if left.Capacity == nil || right.Capacity == nil { + return left.Capacity == right.Capacity + } + // ObservedAt describes when the controller sampled capacity. It is a + // response freshness detail, not queue identity, and must not create a new + // persisted projection on every refresh. + return left.Capacity.InUse == right.Capacity.InUse && + left.Capacity.Limit == right.Capacity.Limit +} + func cloneActiveError(value *ActiveErrorSummary) *ActiveErrorSummary { if value == nil { return nil diff --git a/apps/backend/internal/task/statussummary/projector_events.go b/apps/backend/internal/task/statussummary/projector_events.go index 9b0439867c..04bdcb4068 100644 --- a/apps/backend/internal/task/statussummary/projector_events.go +++ b/apps/backend/internal/task/statussummary/projector_events.go @@ -316,11 +316,16 @@ func (p *Projector) restorePersistedState(ctx context.Context, taskID string, st if err := p.restorePullRequestObservations(ctx, taskID, state); err != nil { return err } + if err := p.restoreLaunchQueue(ctx, taskID, state); err != nil { + return err + } return nil } func applySummaryBaseline(state *projectionState, summary *TaskStatusSummary) { state.queuedCount = summary.QueuedPromptCount + state.launchQueue = cloneLaunchQueue(summary.LaunchQueue) + state.launchQueueObserved = summary.LaunchQueue != nil state.taskPending = summary.PendingAction state.lastActivityAt = maxTimePtr(state.lastActivityAt, summary.LastActivityAt) if summary.PrimarySession != nil && summary.PrimarySession.ID != "" { @@ -412,6 +417,22 @@ func (p *Projector) rebaseProjectionStateFromCurrent( if err := p.restorePullRequestObservations(ctx, taskID, state); err != nil { return err } + if err := p.restoreLaunchQueue(ctx, taskID, state); err != nil { + return err + } + return nil +} + +func (p *Projector) restoreLaunchQueue(ctx context.Context, taskID string, state *projectionState) error { + if p.loadLaunchQueue == nil { + return nil + } + queue, err := p.loadLaunchQueue(ctx, taskID) + if err != nil { + return fmt.Errorf("load launch queue for task status summary %q: %w", taskID, err) + } + state.launchQueue = cloneLaunchQueue(queue) + state.launchQueueObserved = true return nil } diff --git a/apps/backend/internal/task/statussummary/projector_test.go b/apps/backend/internal/task/statussummary/projector_test.go index 2fee9e338b..a6791a06d0 100644 --- a/apps/backend/internal/task/statussummary/projector_test.go +++ b/apps/backend/internal/task/statussummary/projector_test.go @@ -1281,3 +1281,21 @@ func TestPendingActionForMessagePrefersExactRequestType(t *testing.T) { } } } + +func TestEqualLaunchQueueComparesCapacityValues(t *testing.T) { + queuedAt := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + left := &LaunchQueueSummary{ + SessionID: "session-luna", QueuedAt: queuedAt, + Reason: LaunchQueueReasonSessionCapacity, Retrying: true, + Capacity: &LaunchQueueCapacity{InUse: 5, Limit: 5, ObservedAt: queuedAt}, + } + right := *left + right.Capacity = &LaunchQueueCapacity{InUse: 5, Limit: 5, ObservedAt: queuedAt.Add(time.Minute)} + if !equalLaunchQueue(left, &right) { + t.Fatal("capacity observation-only changes should not create a new queue value") + } + right.Capacity.InUse = 4 + if equalLaunchQueue(left, &right) { + t.Fatal("capacity value changes must remain observable") + } +} diff --git a/apps/backend/internal/task/statussummary/rebuild.go b/apps/backend/internal/task/statussummary/rebuild.go index 4e8d64b5ba..ea5ea9b338 100644 --- a/apps/backend/internal/task/statussummary/rebuild.go +++ b/apps/backend/internal/task/statussummary/rebuild.go @@ -64,6 +64,7 @@ type RebuildInput struct { // QueuedPromptCount is the authoritative pending prompt count for the task // (all sessions). Supplied by the caller; 0 means nothing is queued. QueuedPromptCount int + LaunchQueue *LaunchQueueSummary Now time.Time } @@ -73,18 +74,20 @@ type RebuildInput struct { // replaying any session stream. func BuildFromAuthoritative(input RebuildInput) TaskStatusSummary { state := &projectionState{ - sessions: make(map[string]sessionObservation, len(input.Sessions)), - pending: make(map[string]string, len(input.PendingActions)), - pendingRequests: make(map[string]pendingRequestIdentity), - errors: make(map[string]*ActiveErrorSummary), - clearedErrorStamps: make(map[string]string), - git: make(map[string]GitSummary, len(input.Git)), - prs: make(map[string]pullRequestObservation, len(input.PullRequests)), - pendingObserved: true, - activityObserved: input.ActivityObserved, - errorsObserved: true, - gitObserved: input.GitObserved, - prObserved: input.PRObserved, + sessions: make(map[string]sessionObservation, len(input.Sessions)), + pending: make(map[string]string, len(input.PendingActions)), + pendingRequests: make(map[string]pendingRequestIdentity), + errors: make(map[string]*ActiveErrorSummary), + clearedErrorStamps: make(map[string]string), + git: make(map[string]GitSummary, len(input.Git)), + prs: make(map[string]pullRequestObservation, len(input.PullRequests)), + pendingObserved: true, + activityObserved: input.ActivityObserved, + errorsObserved: true, + gitObserved: input.GitObserved, + prObserved: input.PRObserved, + launchQueueObserved: true, + launchQueue: cloneLaunchQueue(input.LaunchQueue), } for _, inputSession := range input.Sessions { if strings.TrimSpace(inputSession.ID) == "" { diff --git a/apps/backend/internal/workflow/stepentry/stepentry.go b/apps/backend/internal/workflow/stepentry/stepentry.go index c4ddf854de..2243bf907e 100644 --- a/apps/backend/internal/workflow/stepentry/stepentry.go +++ b/apps/backend/internal/workflow/stepentry/stepentry.go @@ -44,8 +44,12 @@ type PendingAllocation struct { // it onto the context via WithResultHolder, and reads it back after the // write-site transaction commits. It is populated by the repository layer. type AllocationResult struct { - EntryID int64 - EntrySeq int64 + // TransitionID is the immutable workflow-step transition ledger identity. + // It is distinct from EntryID, which identifies the marker-bearing step + // entry row allocated for this arrival. + TransitionID int64 + EntryID int64 + EntrySeq int64 } // MarkerState is a step-entry marker's terminal (or in-progress) state. diff --git a/apps/web/components/task/launch-queue-status.test.tsx b/apps/web/components/task/launch-queue-status.test.tsx new file mode 100644 index 0000000000..1bf7ddccba --- /dev/null +++ b/apps/web/components/task/launch-queue-status.test.tsx @@ -0,0 +1,215 @@ +/* eslint-disable sonarjs/no-duplicate-string -- Repeated wire fixtures keep queue ownership assertions readable. */ +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TaskStatusSummaryLaunchQueue } from "@/lib/types/task-status-summary"; +import { + hasWorkflowParkingMarker, + LaunchQueueStatus, + ParkedSessionNote, +} from "./launch-queue-status"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, providedValues?: Record) => { + const values = providedValues ?? {}; + const labels: Record = { + "task:launchQueueTitle": "Automatic launch", + "task:launchQueueLabel": "Queued", + "task:launchQueueIndicator": "Automatic launch queued", + "task:launchQueueDestination": `Destination: ${values.destination ?? ""}`, + "task:launchQueueWaitingCapacity": "Waiting for session capacity.", + "task:launchQueueOwnershipUnavailable": "Launch ownership is unavailable.", + "task:launchQueueReplayError": "The queued launch needs attention.", + "task:launchQueueReplayErrorStopped": "The queued launch needs attention.", + "task:launchQueueCapacity": `${values.inUse ?? ""} of ${values.limit ?? ""} sessions in use. Checked ${values.checkedAt ?? ""}.`, + "task:launchQueueCapacityStale": `${values.inUse ?? ""} of ${values.limit ?? ""} sessions in use. Capacity data is stale. Last checked ${values.checkedAt ?? ""}.`, + "task:launchQueueCapacityDisconnected": `${values.inUse ?? ""} of ${values.limit ?? ""} sessions in use. Capacity connection is unavailable. Last checked ${values.checkedAt ?? ""}.`, + "task:launchQueueCapacityUnavailable": "Capacity is unavailable.", + "task:launchQueueCapacityUnavailableStopped": "Capacity information is unavailable.", + "task:launchQueueSince": `Queued since ${values.time ?? ""}.`, + "task:launchQueueAutomaticRetry": "Kandev will retry automatically.", + "task:launchQueueRetryPending": "Retry pending.", + "task:launchQueueRetryStopped": "Automatic retry stopped.", + "task:launchQueueUnknownDestination": "the queued session", + }; + return labels[key] ?? key; + }, + }), +})); + +vi.mock("@/components/state-provider", () => ({ + useOptionalAppStore: ( + selector: (state: { + agentProfiles: { items: Array<{ id: string; label: string }> }; + connection: { status: string }; + }) => unknown, + fallback: unknown, + ) => + selector({ + agentProfiles: { items: [{ id: "luna-profile", label: "Luna" }] }, + connection: { status: "connected" }, + }) || fallback, +})); + +vi.mock("@/lib/utils", () => ({ + formatRelativeTime: (value: string) => (value ? "just now" : ""), +})); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +const QUEUE_WITH_CAPACITY: TaskStatusSummaryLaunchQueue = { + session_id: "luna-session", + agent_profile_id: "luna-profile", + queued_at: "2026-09-16T20:15:44Z", + reason: "session_capacity", + retrying: true, + capacity: { + in_use: 5, + limit: 5, + observed_at: "2026-09-16T20:15:44Z", + }, +}; + +const QUEUE_STALE_CAPACITY: TaskStatusSummaryLaunchQueue = { + ...QUEUE_WITH_CAPACITY, + queued_at: "2026-09-17T09:59:00Z", + capacity: { ...QUEUE_WITH_CAPACITY.capacity!, observed_at: "2026-09-16T20:15:44Z" }, +}; + +const QUEUE_CURRENT_CAPACITY: TaskStatusSummaryLaunchQueue = { + ...QUEUE_WITH_CAPACITY, + queued_at: "2026-09-17T09:59:00Z", + capacity: { ...QUEUE_WITH_CAPACITY.capacity!, observed_at: "2026-09-17T09:59:59Z" }, +}; + +const QUEUE_STOPPED_REPLAY: TaskStatusSummaryLaunchQueue = { + session_id: "luna-session", + agent_profile_id: "luna-profile", + queued_at: "2026-09-17T09:59:00Z", + reason: "replay_error", + retrying: false, +}; + +describe("LaunchQueueStatus", () => { + it("renders the destination and bounded capacity details outside the transcript", () => { + render(); + + expect(screen.getByTestId("task-launch-queue-status").getAttribute("role")).toBeNull(); + expect(screen.getByTestId("task-launch-queue-live-status").getAttribute("role")).toBe("status"); + expect(screen.getByText("Destination: Luna")).toBeTruthy(); + expect(screen.getByText(/5 of 5 sessions in use/)).toBeTruthy(); + expect(screen.getByText(/retry automatically/)).toBeTruthy(); + expect(screen.queryByText(/position|ETA/i)).toBeNull(); + }); + + it("does not render when the queue projection is cleared", () => { + render(); + expect(screen.queryByTestId("task-launch-queue-status")).toBeNull(); + }); + + it("marks an old capacity sample stale after the freshness window", () => { + vi.useFakeTimers(); + const now = new Date("2026-09-17T10:00:00Z"); + vi.setSystemTime(now); + render(); + + expect(screen.getByText(/Capacity data is stale/)).toBeTruthy(); + }); + + it("marks known capacity unavailable while disconnected and keeps ownership visible", () => { + render(); + + expect(screen.getByText("Destination: Luna")).toBeTruthy(); + expect(screen.getByText(/Capacity connection is unavailable/)).toBeTruthy(); + }); + + it("uses the generic destination label when the profile is not loaded", () => { + render( + , + ); + + expect(screen.getByText("Destination: the queued session")).toBeTruthy(); + expect(screen.queryByText(/opaque-profile-id|opaque-session-id/)).toBeNull(); + }); + + it("rerenders the same queue as its capacity sample ages", () => { + vi.useFakeTimers(); + const now = new Date("2026-09-17T10:00:00Z"); + vi.setSystemTime(now); + render(); + + expect(screen.getByText(/Checked just now/)).toBeTruthy(); + act(() => vi.advanceTimersByTime(41_000)); + expect(screen.getByText(/Capacity data is stale/)).toBeTruthy(); + }); +}); + +describe("LaunchQueueStatus retry copy", () => { + it("does not promise a retry when the queue has stopped retrying", () => { + render(); + + expect(screen.getByText("The queued launch needs attention.")).toBeTruthy(); + expect(screen.getByText("Automatic retry stopped.")).toBeTruthy(); + expect(screen.queryByText(/will retry/)).toBeNull(); + }); +}); + +describe("workflow parking presentation", () => { + it.each([ + [undefined, false], + [{}, false], + [ + { + workflow_parking: { + stamp: "", + parked_at: "2026-09-16T20:00:00Z", + source_session_id: "source", + }, + }, + false, + ], + [{ workflow_parking: { stamp: "stamp", parked_at: "", source_session_id: "source" } }, false], + [ + { + workflow_parking: { + stamp: "stamp", + parked_at: "2026-09-16T20:00:00Z", + source_session_id: "", + }, + }, + false, + ], + [ + { + workflow_parking: { + stamp: "stamp", + parked_at: "2026-09-16T20:00:00Z", + source_session_id: "source", + }, + }, + true, + ], + ])("validates %j as %s", (metadata, expected) => { + expect(hasWorkflowParkingMarker(metadata as Record | undefined)).toBe( + expected, + ); + }); + + it("renders the note only when the selected session is parked", () => { + const { rerender } = render(); + expect(screen.queryByTestId("task-parked-session-note")).toBeNull(); + rerender(); + expect(screen.getByTestId("task-parked-session-note")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/launch-queue-status.tsx b/apps/web/components/task/launch-queue-status.tsx new file mode 100644 index 0000000000..8cc5ea5497 --- /dev/null +++ b/apps/web/components/task/launch-queue-status.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { IconClockHour4 } from "@tabler/icons-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useOptionalAppStore } from "@/components/state-provider"; +import { formatRelativeTime } from "@/lib/utils"; +import type { TaskStatusSummaryLaunchQueue } from "@/lib/types/task-status-summary"; +import { + buildLaunchQueueViewModel, + type LaunchQueueViewModel, +} from "@/lib/tasks/launch-queue-view-model"; + +function reasonLabel( + reason: LaunchQueueViewModel["reason"], + retrying: boolean, + t: (key: string) => string, +): string { + switch (reason) { + case "session_capacity": + return t("task:launchQueueWaitingCapacity"); + case "ownership_unavailable": + return t("task:launchQueueOwnershipUnavailable"); + case "replay_error": + return t(retrying ? "task:launchQueueReplayError" : "task:launchQueueReplayErrorStopped"); + } +} + +function retryStatusLabel(view: LaunchQueueViewModel, t: (key: string) => string): string { + if (!view.retrying) return t("task:launchQueueRetryStopped"); + if (view.reason === "session_capacity") return t("task:launchQueueAutomaticRetry"); + return t("task:launchQueueRetryPending"); +} + +function capacityLabel( + view: LaunchQueueViewModel, + isConnected: boolean, + t: (key: string, values?: Record) => string, +): string { + if (!view.capacity) { + return t( + view.retrying + ? "task:launchQueueCapacityUnavailable" + : "task:launchQueueCapacityUnavailableStopped", + ); + } + + let key = "task:launchQueueCapacityStale"; + if (view.capacityFreshness === "current") { + key = "task:launchQueueCapacity"; + } else if (view.capacityFreshness === "stale" && !isConnected) { + key = "task:launchQueueCapacityDisconnected"; + } + return t(key, { + inUse: view.capacity.inUse, + limit: view.capacity.limit, + checkedAt: formatRelativeTime(view.capacity.observedAt), + }); +} + +export function LaunchQueueStatus({ + queue, + isConnected, +}: { + queue: TaskStatusSummaryLaunchQueue | null | undefined; + isConnected?: boolean; +}) { + const { t } = useTranslation(); + const connected = useOptionalAppStore((state) => state.connection.status === "connected", true); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!queue) return; + const interval = window.setInterval(() => setNow(Date.now()), 1_000); + return () => window.clearInterval(interval); + }, [queue]); + + const view = buildLaunchQueueViewModel(queue, { + now, + isConnected: isConnected ?? connected, + }); + if (!view) return null; + + return ; +} + +function LaunchQueueStatusContent({ + view, + t, + isConnected, +}: { + view: LaunchQueueViewModel; + t: (key: string, values?: Record) => string; + isConnected: boolean; +}) { + const destinationLabel = useOptionalAppStore((state) => { + if (!view.destinationId) return ""; + return ( + state.agentProfiles.items.find((profile) => profile.id === view.destinationId)?.label ?? "" + ); + }, ""); + + const destination = destinationLabel || t("task:launchQueueUnknownDestination"); + const retryLabel = retryStatusLabel(view, t); + + return ( +
+ + {t("task:launchQueueIndicator")} + +
+
+
+ ); +} + +export function hasWorkflowParkingMarker( + metadata: Record | null | undefined, +): boolean { + const marker = metadata?.workflow_parking; + if (!marker || typeof marker !== "object" || Array.isArray(marker)) return false; + const value = marker as Record; + return ( + typeof value.stamp === "string" && + value.stamp.trim() !== "" && + typeof value.parked_at === "string" && + value.parked_at.trim() !== "" && + typeof value.source_session_id === "string" && + value.source_session_id.trim() !== "" + ); +} + +export function ParkedSessionNote({ visible }: { visible: boolean }) { + const { t } = useTranslation(); + if (!visible) return null; + return ( +
+ {t("task:parkedSessionNote")} +
+ ); +} diff --git a/apps/web/components/task/mobile/session-mobile-layout.tsx b/apps/web/components/task/mobile/session-mobile-layout.tsx index 7082853816..f24d264274 100644 --- a/apps/web/components/task/mobile/session-mobile-layout.tsx +++ b/apps/web/components/task/mobile/session-mobile-layout.tsx @@ -39,6 +39,8 @@ import { PromptHistoryPanelContent } from "../prompt-history-panel-content"; import { parsePluginPanelId } from "@/lib/state/layout-manager/plugin-panels"; import { useEffectiveMobilePanel, type MobileReviewSource } from "./mobile-plugin-panel-lifecycle"; import { useTranslation } from "react-i18next"; +import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary"; +import { LaunchQueueStatus } from "../launch-queue-status"; export { resolveMobilePluginPanel } from "./mobile-plugin-panel-lifecycle"; @@ -123,6 +125,7 @@ function MobileChatPanelContent({ isVisible: boolean; }) { const { t } = useTranslation(); + const launchStatusSummary = useTaskStatusSummary(activeTaskId, undefined); if (!activeTaskId) { return (
@@ -132,6 +135,7 @@ function MobileChatPanelContent({ } return (
+
@@ -151,6 +155,7 @@ function MobileChatPanelContent({ pendingScrollTarget={scrollTarget} isVisible={isVisible} onPendingScrollConsumed={onScrollTargetConsumed} + hideLaunchQueueStatus /> )}
diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts index 9d645d0103..0c7459ae97 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts @@ -54,6 +54,10 @@ function sheetPendingFlags(task: KanbanState["tasks"][number]) { }; } +function sheetLaunchQueue(task: KanbanState["tasks"][number]) { + return task.statusSummary?.launch_queue; +} + function sheetStatus(task: KanbanState["tasks"][number], ctx: SheetItemCtx) { const summary = task.statusSummary; const hasSummary = summary != null; @@ -126,6 +130,7 @@ export function toSheetItem( automaticColor: automaticColor?.color, automaticColorSource: automaticColor?.source, queuedCount: task.statusSummary?.queued_prompt_count, + launchQueue: sheetLaunchQueue(task), wipQueue: ctx.wipQueueByTaskId?.get(task.id), }; } diff --git a/apps/web/components/task/task-chat-panel.launch-error.test.tsx b/apps/web/components/task/task-chat-panel.launch-error.test.tsx index 5eb2b6aba3..0d94a26a0a 100644 --- a/apps/web/components/task/task-chat-panel.launch-error.test.tsx +++ b/apps/web/components/task/task-chat-panel.launch-error.test.tsx @@ -28,6 +28,7 @@ const priorTranscriptMessage = { } as unknown as Message; const appStoreState = { + connection: { status: "connected" }, userSettings: { showAnchoredPromptBar: false, showScrollToLastPrompt: false, @@ -71,6 +72,8 @@ vi.mock("./panel-primitives", () => ({ vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: typeof appStoreState) => unknown) => selector(appStoreState), + useOptionalAppStore: (selector: (state: typeof appStoreState) => unknown, fallback: unknown) => + selector(appStoreState) ?? fallback, useAppStoreApi: () => ({ getState: () => appStoreState, }), diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 8fc4260c91..c950677737 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -58,6 +58,11 @@ import { useTaskLaunchErrorContext } from "./task-launch-error-context"; import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary"; import { TaskMarkdownFileLinkProvider } from "@/components/shared/task-markdown-file-link-provider"; import { statusSummaryTaskError } from "@/lib/task-status-summary"; +import { + hasWorkflowParkingMarker, + LaunchQueueStatus, + ParkedSessionNote, +} from "./launch-queue-status"; /** Returns a `clarificationKey` that increments each time a pending * clarification is resolved, letting the composer reset its input state for @@ -571,6 +576,8 @@ type TaskChatPanelProps = { onOpenFileAtLine?: (filePath: string) => void; /** Hide the sessions dropdown (session tabs in dockview replace it) */ hideSessionsDropdown?: boolean; + /** Mobile layout renders the task queue above its session picker. */ + hideLaunchQueueStatus?: boolean; /** * Embedded multi-panel hosts do not own the global workbench or shortcuts. * They keep the conversation and composer, but suppress those side effects. @@ -996,13 +1003,15 @@ export const TaskChatPanel = memo(function TaskChatPanel({ pendingScrollToMessageId = null, pendingScrollTarget, onPendingScrollConsumed, + hideLaunchQueueStatus = false, }: TaskChatPanelProps) { const isArchived = useIsTaskArchived(); const chatInputRef = useRef(null); const launchErrorContext = useTaskLaunchErrorContext(); + const summaryTaskId = statusTaskId ?? taskIdHint ?? launchErrorContext?.taskId ?? null; const launchStatusSummary = useTaskStatusSummary( - launchErrorContext?.taskId, - launchErrorContext?.statusSummary, + summaryTaskId, + launchErrorContext?.taskId === summaryTaskId ? launchErrorContext.statusSummary : undefined, ); const { t } = useTranslation(); useSettingsData(true); @@ -1169,6 +1178,8 @@ export const TaskChatPanel = memo(function TaskChatPanel({ onMouseDown={handlePanelMouseDown} className="outline-none" > + {!hideLaunchQueueStatus && } + ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ sessionPollMode: { bySessionId: {} } }), +})); + +vi.mock("@/lib/config", () => ({ isDebugUI: () => false })); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@kandev/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, +})); + +afterEach(() => cleanup()); + +const QUEUE = { + session_id: "session-1", + agent_profile_id: "profile-1", + queued_at: "2026-09-17T10:00:00Z", + reason: "session_capacity" as const, + retrying: true, +}; + +describe("TaskItemStatsRow launch queue indicator", () => { + it("does not activate its parent row on click or keyboard input", () => { + const parentClick = vi.fn(); + const parentKeyDown = vi.fn(); + render( +
+ +
, + ); + + const indicator = screen.getByTestId("sidebar-task-launch-queue"); + fireEvent.click(indicator); + fireEvent.keyDown(indicator, { key: "Enter" }); + fireEvent.keyDown(indicator, { key: " " }); + + expect(parentClick).not.toHaveBeenCalled(); + expect(parentKeyDown).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/task/task-item-stats-row.tsx b/apps/web/components/task/task-item-stats-row.tsx index 725b1a2dba..9d79e5d248 100644 --- a/apps/web/components/task/task-item-stats-row.tsx +++ b/apps/web/components/task/task-item-stats-row.tsx @@ -9,6 +9,7 @@ import { isDebugUI } from "@/lib/config"; import type { SessionPollMode } from "@/lib/state/slices/session-runtime/types"; import { cn, formatRelativeTime } from "@/lib/utils"; import type { WipQueueStatus } from "@/lib/kanban/wip-queue"; +import type { TaskStatusSummaryLaunchQueue } from "@/lib/types/task-status-summary"; import { SIDEBAR_TASK_ROW_DETAIL_KEYS, type SidebarTaskRowDetail, @@ -124,6 +125,35 @@ function WipQueueIndicator({ wipQueue }: { wipQueue?: WipQueueStatus }) { ); } +function LaunchQueueIndicator({ + launchQueue, +}: { + launchQueue?: TaskStatusSummaryLaunchQueue | null; +}) { + const { t } = useTranslation(); + if (!launchQueue) return null; + const label = t("task:launchQueueIndicator"); + return ( + + + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + className="inline-flex shrink-0 items-center gap-0.5 text-muted-foreground/70 outline-none focus-visible:ring-1 focus-visible:ring-ring" + > + + + {label} + + ); +} + function PollModeIndicator({ pollMode, config, @@ -154,6 +184,7 @@ function hasStatsToRender({ pollMode, queuedCount, wipQueue, + launchQueue, }: { hasRelativeTime: boolean; hasRepository: boolean; @@ -161,9 +192,16 @@ function hasStatsToRender({ pollMode: SessionPollMode | null; queuedCount?: number; wipQueue?: WipQueueStatus; + launchQueue?: TaskStatusSummaryLaunchQueue | null; }): boolean { return Boolean( - hasRelativeTime || hasRepository || hasPullRequestNumber || pollMode || queuedCount || wipQueue, + hasRelativeTime || + hasRepository || + hasPullRequestNumber || + pollMode || + queuedCount || + wipQueue || + launchQueue, ); } @@ -179,6 +217,7 @@ export function TaskItemStatsRow({ primarySessionId, queuedCount, wipQueue, + launchQueue, detailOrder = [...SIDEBAR_TASK_ROW_DETAIL_KEYS], showRelativeTime = true, showRepository = true, @@ -191,6 +230,7 @@ export function TaskItemStatsRow({ primarySessionId?: string | null; queuedCount?: number; wipQueue?: WipQueueStatus; + launchQueue?: TaskStatusSummaryLaunchQueue | null; detailOrder?: SidebarTaskRowDetail[]; showRelativeTime?: boolean; showRepository?: boolean; @@ -213,6 +253,7 @@ export function TaskItemStatsRow({ pollMode, queuedCount, wipQueue, + launchQueue, }) ) { return null; @@ -234,6 +275,7 @@ export function TaskItemStatsRow({ )} + { + test("keeps queue status visible while inspecting the parked conversation", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + test.setTimeout(240_000); + const releaseCeiling = await backend.useEnv({ KANDEV_MAX_CONCURRENT_SESSIONS: "1" }); + let fillerSessionId: string | undefined; + + try { + const scenario = await createQueuedSessionOwnershipScenario( + apiClient, + seedData, + "Mobile queued ownership", + ); + fillerSessionId = scenario.fillerSessionId; + const sourceMessagesBefore = await sessionMessageIds(apiClient, scenario.sourceSessionId); + + await testPage.goto(`/t/${scenario.taskId}`); + const sessionPage = new SessionPage(testPage); + await sessionPage.waitForLoad(); + + const mobileLayout = testPage.locator('[data-testid="mobile-task-layout"]:visible'); + const queueStatus = mobileLayout.locator('[data-testid="task-launch-queue-status"]:visible'); + await expect(queueStatus).toBeVisible(); + await expect(queueStatus).toContainText("Queued"); + await expect(queueStatus).toContainText("Waiting for session capacity"); + await expect(queueStatus).toContainText("1 of 1"); + await expect(queueStatus).toContainText(scenario.destinationProfileName); + await assertNoDocumentHorizontalOverflow(testPage, "queued launch mobile detail"); + + await mobileLayout.getByTestId("mobile-session-menu").tap(); + const taskDrawer = testPage.getByRole("dialog", { name: "Tasks" }); + const taskRow = taskDrawer.getByTestId("sidebar-task-item").filter({ + hasText: scenario.taskTitle, + }); + await expect(taskRow).toBeVisible(); + await expect(taskRow.getByTestId("sidebar-task-launch-queue")).toBeVisible(); + await assertNoDocumentHorizontalOverflow(testPage, "queued launch mobile task drawer"); + await testPage.keyboard.press("Escape"); + await expect(taskDrawer).not.toBeVisible(); + + await mobileLayout.getByTestId("mobile-sessions-pill").tap(); + const sessionPicker = testPage.getByRole("dialog", { name: "Sessions" }); + const sourceRow = sessionPicker.getByTestId(`mobile-session-row-${scenario.sourceSessionId}`); + await expect(sourceRow).toBeVisible(); + await expect( + sessionPicker.getByTestId(`mobile-session-row-${scenario.destinationSessionId}`), + ).toBeVisible(); + const sourceRowBox = await sourceRow.boundingBox(); + expect(sourceRowBox).not.toBeNull(); + expect(sourceRowBox!.height).toBeGreaterThanOrEqual(44); + await sourceRow.tap(); + await expect(sessionPicker).not.toBeVisible(); + + await expect(queueStatus).toBeVisible(); + await expect( + testPage.locator('[data-testid="task-parked-session-note"]:visible'), + ).toBeVisible(); + await expectSessionMessagesUnchanged( + apiClient, + scenario.sourceSessionId, + sourceMessagesBefore, + ); + + await apiClient.stopSession({ + session_id: scenario.fillerSessionId, + reason: "queued-session-ownership mobile e2e capacity release", + force: true, + }); + fillerSessionId = undefined; + await waitForLaunchQueueCleared(apiClient, seedData.workspaceId, scenario.taskId); + await waitForSessionState( + apiClient, + scenario.taskId, + scenario.destinationSessionId, + "WAITING_FOR_INPUT", + 60_000, + ); + await expectSessionMessagesUnchanged( + apiClient, + scenario.sourceSessionId, + sourceMessagesBefore, + ); + await assertNoDocumentHorizontalOverflow(testPage, "queued launch mobile dispatched detail"); + } finally { + if (fillerSessionId) { + await apiClient + .stopSession({ + session_id: fillerSessionId, + reason: "queued-session-ownership mobile e2e cleanup", + force: true, + }) + .catch(() => undefined); + } + await releaseCeiling(); + } + }); +}); diff --git a/apps/web/e2e/tests/workflow/queued-session-ownership-helpers.ts b/apps/web/e2e/tests/workflow/queued-session-ownership-helpers.ts new file mode 100644 index 0000000000..9e6adbcc78 --- /dev/null +++ b/apps/web/e2e/tests/workflow/queued-session-ownership-helpers.ts @@ -0,0 +1,208 @@ +import { expect } from "../../fixtures/test-base"; +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import type { TaskStatusSummaryLaunchQueue } from "../../../lib/types/task-status-summary"; +import { + createWorkflowAgentProfiles, + waitForWorkflowProfileSession, +} from "./workflow-agent-switch-helpers"; + +export type QueuedSessionOwnershipScenario = { + taskId: string; + taskTitle: string; + workflowId: string; + sourceSessionId: string; + destinationSessionId: string; + fillerSessionId: string; + queue: TaskStatusSummaryLaunchQueue; + destinationProfileName: string; +}; + +export const QUEUED_DESTINATION_MARKER = "queued destination delivery"; + +export async function waitForLaunchQueue( + apiClient: ApiClient, + workspaceId: string, + taskId: string, + timeoutMs = 30_000, +): Promise { + let latest: TaskStatusSummaryLaunchQueue | null | undefined; + await expect + .poll( + async () => { + const { tasks } = await apiClient.listTasks(workspaceId); + latest = tasks.find((task) => task.id === taskId)?.status_summary?.launch_queue; + return latest ?? null; + }, + { timeout: timeoutMs, message: `task ${taskId} never exposed its launch queue` }, + ) + .not.toBeNull(); + if (!latest) throw new Error(`task ${taskId} exposed no launch queue after polling`); + return latest; +} + +export async function waitForLaunchQueueCleared( + apiClient: ApiClient, + workspaceId: string, + taskId: string, + timeoutMs = 60_000, +): Promise { + await expect + .poll( + async () => { + const { tasks } = await apiClient.listTasks(workspaceId); + return tasks.find((task) => task.id === taskId)?.status_summary?.launch_queue ?? null; + }, + { timeout: timeoutMs, message: `task ${taskId} launch queue did not clear` }, + ) + .toBeNull(); +} + +export async function waitForSessionState( + apiClient: ApiClient, + taskId: string, + sessionId: string, + expectedState: string, + timeoutMs = 30_000, +): Promise { + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(taskId); + return sessions.find((session) => session.id === sessionId)?.state ?? ""; + }, + { + timeout: timeoutMs, + message: `session ${sessionId} did not become ${expectedState}`, + }, + ) + .toBe(expectedState); +} + +export async function waitForTaskState( + apiClient: ApiClient, + taskId: string, + expectedState: string, + timeoutMs = 30_000, +): Promise { + await expect + .poll(async () => (await apiClient.getTask(taskId)).state ?? "", { + timeout: timeoutMs, + message: `task ${taskId} did not become ${expectedState}`, + }) + .toBe(expectedState); +} + +export async function sessionMessageIds( + apiClient: ApiClient, + sessionId: string, +): Promise { + const { messages } = await apiClient.listSessionMessages(sessionId); + return messages.map((message) => message.id); +} + +export async function expectSessionMessagesUnchanged( + apiClient: ApiClient, + sessionId: string, + expectedMessageIds: string[], + timeoutMs = 5_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let unexpectedMessageIds: string[] | null = null; + await expect + .poll( + async () => { + const actualMessageIds = await sessionMessageIds(apiClient, sessionId); + if (JSON.stringify(actualMessageIds) !== JSON.stringify(expectedMessageIds)) { + unexpectedMessageIds = actualMessageIds; + return true; + } + return Date.now() >= deadline; + }, + { + // Give the final poll enough time to observe the end of the stability + // window. With the same timeout on both clocks, an unchanged list can + // be reported as a timeout instead of a successful invariant check. + timeout: timeoutMs + 1_000, + message: `session ${sessionId} received an unexpected message`, + }, + ) + .toBe(true); + if (unexpectedMessageIds) { + throw new Error( + `session ${sessionId} received unexpected messages: ${JSON.stringify(unexpectedMessageIds)}`, + ); + } +} + +/** + * Create the smallest real workflow that can demonstrate an accepted launch + * waiting behind the instance ceiling. The filler task owns the only admitted + * slot; moving the target then creates its exact destination before retry. + */ +export async function createQueuedSessionOwnershipScenario( + apiClient: ApiClient, + seedData: SeedData, + name: string, +): Promise { + const { profileA, profileB } = await createWorkflowAgentProfiles(apiClient); + const workflow = await apiClient.createWorkflow(seedData.workspaceId, `${name} workflow`); + const sourceStep = await apiClient.createWorkflowStep(workflow.id, "Source", 0, { + is_start_step: true, + agent_profile_id: profileA.id, + profile_session_start_policy: "new", + profile_session_end_policy: "park", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + const destinationStep = await apiClient.createWorkflowStep(workflow.id, "Destination", 1, { + agent_profile_id: profileB.id, + profile_session_start_policy: "new", + profile_session_end_policy: "park", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + await apiClient.updateWorkflowStep(destinationStep.id, { + prompt: `e2e:message("${QUEUED_DESTINATION_MARKER}")`, + }); + + const taskTitle = `${name} target`; + const target = await apiClient.createTaskWithAgent(seedData.workspaceId, taskTitle, profileA.id, { + workflow_id: workflow.id, + workflow_step_id: sourceStep.id, + repository_ids: [seedData.repositoryId], + description: "Prepare the source conversation for inspection", + }); + const sourceSessionId = await waitForWorkflowProfileSession(apiClient, target.id, profileA.id); + + const filler = await apiClient.createTaskWithAgent( + seedData.workspaceId, + `${name} capacity holder`, + profileA.id, + { + workflow_id: workflow.id, + workflow_step_id: sourceStep.id, + repository_ids: [seedData.repositoryId], + description: "/slow 120s", + }, + ); + if (!filler.session_id) throw new Error("capacity-holder task did not return a session_id"); + await waitForSessionState(apiClient, filler.id, filler.session_id, "RUNNING"); + + await apiClient.moveTask(target.id, workflow.id, destinationStep.id); + const queue = await waitForLaunchQueue(apiClient, seedData.workspaceId, target.id); + if (!queue.session_id) + throw new Error("queued workflow launch did not identify a destination session"); + + await waitForSessionState(apiClient, target.id, queue.session_id, "CREATED"); + await waitForTaskState(apiClient, target.id, "SCHEDULING"); + + return { + taskId: target.id, + taskTitle, + workflowId: workflow.id, + sourceSessionId, + destinationSessionId: queue.session_id, + fillerSessionId: filler.session_id, + queue, + destinationProfileName: profileB.name, + }; +} diff --git a/apps/web/e2e/tests/workflow/queued-session-ownership.spec.ts b/apps/web/e2e/tests/workflow/queued-session-ownership.spec.ts new file mode 100644 index 0000000000..d9c58452eb --- /dev/null +++ b/apps/web/e2e/tests/workflow/queued-session-ownership.spec.ts @@ -0,0 +1,116 @@ +import { expect, test } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import { + createQueuedSessionOwnershipScenario, + expectSessionMessagesUnchanged, + QUEUED_DESTINATION_MARKER, + sessionMessageIds, + waitForLaunchQueueCleared, + waitForSessionState, + waitForTaskState, +} from "./queued-session-ownership-helpers"; + +test.describe("Queued session ownership", () => { + test("keeps passive desktop inspection on the parked session and dispatches the queued destination once", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + test.setTimeout(240_000); + const releaseCeiling = await backend.useEnv({ KANDEV_MAX_CONCURRENT_SESSIONS: "1" }); + let fillerSessionId: string | undefined; + + try { + const scenario = await createQueuedSessionOwnershipScenario( + apiClient, + seedData, + "Desktop queued ownership", + ); + fillerSessionId = scenario.fillerSessionId; + + const sourceMessagesBefore = await sessionMessageIds(apiClient, scenario.sourceSessionId); + + await testPage.goto(`/t/${scenario.taskId}`); + const sessionPage = new SessionPage(testPage); + await sessionPage.waitForLoad(); + + const queueStatus = testPage.locator('[data-testid="task-launch-queue-status"]:visible'); + await expect(queueStatus).toBeVisible(); + await expect(queueStatus).toContainText("Queued"); + await expect(queueStatus).toContainText("Waiting for session capacity"); + await expect(queueStatus).toContainText("1 of 1"); + await expect(queueStatus).toContainText(scenario.destinationProfileName); + await expect( + sessionPage.sidebarTaskItem(scenario.taskTitle).getByTestId("sidebar-task-launch-queue"), + ).toBeVisible(); + + await sessionPage.sessionTabBySessionId(scenario.sourceSessionId).click(); + await expect(queueStatus).toBeVisible(); + await expect( + testPage.locator('[data-testid="task-parked-session-note"]:visible'), + ).toBeVisible(); + await expectSessionMessagesUnchanged( + apiClient, + scenario.sourceSessionId, + sourceMessagesBefore, + ); + + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(scenario.taskId); + return sessions.find((session) => session.id === scenario.destinationSessionId)?.state; + }, + { timeout: 5_000, message: "passive inspection changed the queued destination" }, + ) + .toBe("CREATED"); + await waitForTaskState(apiClient, scenario.taskId, "SCHEDULING", 5_000); + + await apiClient.stopSession({ + session_id: scenario.fillerSessionId, + reason: "queued-session-ownership e2e capacity release", + force: true, + }); + fillerSessionId = undefined; + await waitForLaunchQueueCleared(apiClient, seedData.workspaceId, scenario.taskId); + await waitForSessionState( + apiClient, + scenario.taskId, + scenario.destinationSessionId, + "WAITING_FOR_INPUT", + 60_000, + ); + await expectSessionMessagesUnchanged( + apiClient, + scenario.sourceSessionId, + sourceMessagesBefore, + ); + + const destinationMessages = ( + await apiClient.listSessionMessages(scenario.destinationSessionId) + ).messages; + const workflowDeliveries = destinationMessages.filter( + (message) => + message.author_type === "user" && + message.metadata?.workflow_message === true && + message.content.includes(QUEUED_DESTINATION_MARKER), + ); + expect(workflowDeliveries).toHaveLength(1); + expect( + destinationMessages.some((message) => message.content.includes(QUEUED_DESTINATION_MARKER)), + ).toBe(true); + } finally { + if (fillerSessionId) { + await apiClient + .stopSession({ + session_id: fillerSessionId, + reason: "queued-session-ownership e2e cleanup", + force: true, + }) + .catch(() => undefined); + } + await releaseCeiling(); + } + }); +}); diff --git a/apps/web/hooks/domains/session/use-ensure-task-session.test.ts b/apps/web/hooks/domains/session/use-ensure-task-session.test.ts index 3b9fbcc214..ce4bf67ce1 100644 --- a/apps/web/hooks/domains/session/use-ensure-task-session.test.ts +++ b/apps/web/hooks/domains/session/use-ensure-task-session.test.ts @@ -90,7 +90,9 @@ describe("useEnsureTaskSession", () => { const { result } = renderHook(() => useEnsureTaskSession(TASK)); expect(mockEnsureTaskSession).toHaveBeenCalledTimes(1); - expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-1", undefined); + expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-1", { + activationSource: "session_open", + }); expect(result.current.status).toBe("preparing"); await flushMicrotasks(); expect(result.current.status).toBe("idle"); @@ -220,7 +222,9 @@ describe("useEnsureTaskSession failed gate changes", () => { await flushMicrotasks(); expect(mockEnsureTaskSession).toHaveBeenCalledTimes(1); - expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-1", undefined); + expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-1", { + activationSource: "session_open", + }); mockStoreState.kanban.steps = [{ id: "step-1", position: 0 }]; rerender(); @@ -264,7 +268,9 @@ describe("useEnsureTaskSession — task changes", () => { expect(mockEnsureTaskSession).toHaveBeenCalledTimes(1); rerender({ task: { id: "task-2" } }); expect(mockEnsureTaskSession).toHaveBeenCalledTimes(2); - expect(mockEnsureTaskSession).toHaveBeenLastCalledWith("task-2", undefined); + expect(mockEnsureTaskSession).toHaveBeenLastCalledWith("task-2", { + activationSource: "session_open", + }); }); }); @@ -319,7 +325,10 @@ describe("useEnsureTaskSession prevent-auto-start gate", () => { useEnsureTaskSession({ id: "task-1", workflowStepId: "step-done", workflowId: "wf-active" }), ); await flushMicrotasks(); - expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-1", { autoStart: false }); + expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-1", { + autoStart: false, + activationSource: "session_open", + }); expect(result.current.status).toBe("idle"); }); @@ -340,7 +349,9 @@ describe("useEnsureTaskSession prevent-auto-start gate", () => { useEnsureTaskSession({ id: "task-1", workflowStepId: "step-1", workflowId: "wf-active" }), ); await flushMicrotasks(); - expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-1", undefined); + expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-1", { + activationSource: "session_open", + }); }); it("does NOT gate a task whose workflow id is missing even when its step id matches the active workflow's terminal step", async () => { @@ -361,7 +372,9 @@ describe("useEnsureTaskSession prevent-auto-start gate", () => { // auto-start the user expects. Missing workflow id → no gate. renderHook(() => useEnsureTaskSession({ id: "task-1", workflowStepId: "step-done" })); await flushMicrotasks(); - expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-1", undefined); + expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-1", { + activationSource: "session_open", + }); }); it("resolves the step list from the multi-workflow snapshot for a cross-workflow task", async () => { @@ -391,7 +404,10 @@ describe("useEnsureTaskSession prevent-auto-start gate", () => { }), ); await flushMicrotasks(); - expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-2", { autoStart: false }); + expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-2", { + autoStart: false, + activationSource: "session_open", + }); }); it("does not gate when the preference is off", async () => { @@ -411,7 +427,9 @@ describe("useEnsureTaskSession prevent-auto-start gate", () => { useEnsureTaskSession({ id: "task-1", workflowStepId: "step-done", workflowId: "wf-active" }), ); await flushMicrotasks(); - expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-1", undefined); + expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-1", { + activationSource: "session_open", + }); }); }); @@ -454,7 +472,10 @@ describe("useEnsureTaskSession late step hydration", () => { // Exactly one ensure, gated: never an ungated call. expect(mockEnsureTaskSession).toHaveBeenCalledTimes(1); - expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-1", { autoStart: false }); + expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-1", { + autoStart: false, + activationSource: "session_open", + }); }); }); @@ -501,6 +522,9 @@ describe("useEnsureTaskSession placeholder snapshot", () => { await flushMicrotasks(); expect(mockEnsureTaskSession).toHaveBeenCalledTimes(1); - expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-2", { autoStart: false }); + expect(mockEnsureTaskSession).toHaveBeenCalledWith("task-2", { + autoStart: false, + activationSource: "session_open", + }); }); }); diff --git a/apps/web/hooks/domains/session/use-ensure-task-session.ts b/apps/web/hooks/domains/session/use-ensure-task-session.ts index 8f31a4b0f3..62494e6bbe 100644 --- a/apps/web/hooks/domains/session/use-ensure-task-session.ts +++ b/apps/web/hooks/domains/session/use-ensure-task-session.ts @@ -199,8 +199,11 @@ export function useEnsureTaskSession( setStatus("preparing"); setError(null); const ensurePromise = isFinalStep - ? ensureTaskSession(taskId, { autoStart: false }) - : ensureTaskSession(taskId); + ? ensureTaskSession(taskId, { + autoStart: false, + activationSource: "session_open", + }) + : ensureTaskSession(taskId, { activationSource: "session_open" }); ensurePromise .then(async () => { if (cancelled || launchedKeyRef.current !== key) return; diff --git a/apps/web/hooks/domains/session/use-session-resumption-operations.ts b/apps/web/hooks/domains/session/use-session-resumption-operations.ts index 58f28f50a9..f0788e6b37 100644 --- a/apps/web/hooks/domains/session/use-session-resumption-operations.ts +++ b/apps/web/hooks/domains/session/use-session-resumption-operations.ts @@ -34,6 +34,8 @@ export type SessionStatus = { is_agent_running: boolean; is_resumable: boolean; needs_resume: boolean; + auto_resume_allowed?: boolean; + auto_resume_blocked_reason?: string; needs_workspace_restore?: boolean; resume_reason?: string; acp_session_id?: string; @@ -198,9 +200,13 @@ type ResumeResponse = { worktree_path?: string; worktree_branch?: string; error?: string; + activation_disposition?: "queued" | "suppressed"; + activation_reason?: string; }; -type LaunchAttempt = { ok: true } | { ok: false; error: Error; archived?: boolean }; +type LaunchAttempt = + | { ok: true; waiting?: boolean } + | { ok: false; error: Error; archived?: boolean }; type FailedLaunchAttempt = Extract; export function isTaskArchivedConflict(error: unknown): boolean { @@ -293,6 +299,16 @@ function applyLaunchSuccess( request: LaunchSessionRequest, context: ResumeLaunchContext, ): LaunchAttempt { + if ( + response.activation_disposition === "queued" || + response.activation_disposition === "suppressed" + ) { + context.setters.setRecoveryFailure?.(null); + context.setters.setResumptionState("idle"); + context.setters.setError(null); + context.setters.setNotice?.(null); + return { ok: true, waiting: true }; + } applyResumeResponse( response, context.taskId, @@ -349,7 +365,7 @@ async function resumeViaLaunch( const { request } = buildRequest(taskId, sessionId); const launchAttempt = await tryLaunch(request, context); if (!canContinue()) return false; - if (launchAttempt.ok) return true; + if (launchAttempt.ok) return !launchAttempt.waiting; if (launchAttempt.archived) { clearArchiveRecovery(setters); return false; @@ -393,27 +409,20 @@ async function restoreAfterResumeFailure( return false; } -/** Attempt resume, silently falling back to restore_workspace on any failure. */ -export async function resumeWithSilentFallback( - taskId: string, - sessionId: string, - session: SessionLike, - setters: ResumeStateSetter, - canContinue: () => boolean = () => true, +async function finishSilentResume( + context: ResumeLaunchContext, + startingProjection: ResumeStartingProjection | null, + resumeAttempt: LaunchAttempt, ): Promise { - if (!canContinue()) return false; - const startingProjection = markSessionStarting(taskId, sessionId, session, setters); - setters.setResumptionState("resuming"); - setters.setRecoveryFailure?.(null); - const context = { taskId, sessionId, session, setters, canContinue }; - const resumeAttempt = await tryLaunch(buildResumeRequest(taskId, sessionId).request, context); + const { setters, canContinue } = context; if (!canContinue()) { startingProjection?.rollback(); return false; } if (resumeAttempt.ok) { setters.setNotice?.(null); - return true; + if (resumeAttempt.waiting) startingProjection?.rollback(); + return !resumeAttempt.waiting; } if (resumeAttempt.archived) { clearArchiveRecovery(setters); @@ -437,6 +446,26 @@ export async function resumeWithSilentFallback( return restored; } +/** Attempt resume, silently falling back to restore_workspace on any failure. */ +export async function resumeWithSilentFallback( + taskId: string, + sessionId: string, + session: SessionLike, + setters: ResumeStateSetter, + canContinue: () => boolean = () => true, +): Promise { + if (!canContinue()) return false; + const startingProjection = markSessionStarting(taskId, sessionId, session, setters); + setters.setResumptionState("resuming"); + setters.setRecoveryFailure?.(null); + const context = { taskId, sessionId, session, setters, canContinue }; + const resumeAttempt = await tryLaunch( + buildResumeRequest(taskId, sessionId, { activationSource: "session_open" }).request, + context, + ); + return finishSilentResume(context, startingProjection, resumeAttempt); +} + /** Run a single launch attempt and retain its failure for the fallback notice. */ async function tryLaunch( request: LaunchSessionRequest, @@ -476,6 +505,7 @@ export type ResumeAction = "running" | "skip" | "resume" | "restore" | "idle"; export function decideResumeAction(status: SessionStatus, preventAutoStart: boolean): ResumeAction { if (status.is_agent_running) return "running"; + if (status.auto_resume_allowed === false) return "idle"; // Completed sessions remain passive until the user explicitly chooses the // completed-chat Resume action. Workspace recovery is separate and does not // revive the agent conversation. diff --git a/apps/web/hooks/domains/session/use-session-resumption.ts b/apps/web/hooks/domains/session/use-session-resumption.ts index 65ddcc4c9e..e9f67136aa 100644 --- a/apps/web/hooks/domains/session/use-session-resumption.ts +++ b/apps/web/hooks/domains/session/use-session-resumption.ts @@ -616,19 +616,37 @@ function useSessionResetAndCheck({ type ManualResumeResponse = Awaited>; -function applyManualResumeResponse( +function isManualResumeWaitingResponse(response: ManualResumeResponse): boolean { + return ( + response.activation_disposition === "queued" || response.activation_disposition === "suppressed" + ); +} + +function applyManualResumeFailure( + response: ManualResumeResponse, + setters: ResumeStateSetter, +): false { + setters.setResumptionState("error"); + setters.setRecoveryFailure?.(null); + setters.setError(response.error ?? t("task:failedToResumeSession")); + return false; +} + +function applyManualResumeWaiting(setters: ResumeStateSetter): false { + setters.setResumptionState("idle"); + setters.setRecoveryFailure?.(null); + setters.setError(null); + setters.setNotice?.(null); + return false; +} + +function applyManualResumeSuccess( response: ManualResumeResponse, taskId: string, sessionId: string, session: SessionLike, setters: ResumeStateSetter, -): boolean { - if (!response.success) { - setters.setResumptionState("error"); - setters.setRecoveryFailure?.(null); - setters.setError(response.error ?? t("task:failedToResumeSession")); - return false; - } +): true { setters.setResumptionState("resumed"); setters.setNotice?.(null); if (response.state) { @@ -648,6 +666,18 @@ function applyManualResumeResponse( return true; } +function applyManualResumeResponse( + response: ManualResumeResponse, + taskId: string, + sessionId: string, + session: SessionLike, + setters: ResumeStateSetter, +): boolean { + if (!response.success) return applyManualResumeFailure(response, setters); + if (isManualResumeWaitingResponse(response)) return applyManualResumeWaiting(setters); + return applyManualResumeSuccess(response, taskId, sessionId, session, setters); +} + function handleManualResumeError( error: unknown, setters: ResumeStateSetter, diff --git a/apps/web/lib/services/session-launch-helpers.ts b/apps/web/lib/services/session-launch-helpers.ts index 85820562b4..5a751c7ab3 100644 --- a/apps/web/lib/services/session-launch-helpers.ts +++ b/apps/web/lib/services/session-launch-helpers.ts @@ -1,4 +1,8 @@ -import type { LaunchSessionRequest, MessageAttachment } from "./session-launch-service"; +import type { + LaunchActivationSource, + LaunchSessionRequest, + MessageAttachment, +} from "./session-launch-service"; import type { TaskPriority } from "@/lib/types/http"; export type LayoutIntentHint = "default" | "plan" | "pr-review" | "keep"; @@ -86,12 +90,17 @@ export function buildStartCreatedRequest( }; } -export function buildResumeRequest(taskId: string, sessionId: string): BuildResult { +export function buildResumeRequest( + taskId: string, + sessionId: string, + opts?: { activationSource?: LaunchActivationSource }, +): BuildResult { return { request: { task_id: taskId, intent: "resume", session_id: sessionId, + ...(opts?.activationSource ? { activation_source: opts.activationSource } : {}), }, layout: "keep", }; diff --git a/apps/web/lib/services/session-launch-service.ts b/apps/web/lib/services/session-launch-service.ts index a99ec37583..1804fb5de3 100644 --- a/apps/web/lib/services/session-launch-service.ts +++ b/apps/web/lib/services/session-launch-service.ts @@ -9,6 +9,8 @@ export type SessionIntent = | "workflow_step" | "restore_workspace"; +export type LaunchActivationSource = "user_action" | "session_open"; + export type MessageAttachment = { type: "image" | "audio" | "resource"; data?: string; @@ -35,6 +37,7 @@ export type LaunchSessionRequest = { skip_message_record?: boolean; auto_start?: boolean; attachments?: MessageAttachment[]; + activation_source?: LaunchActivationSource; }; export type LaunchSessionResponse = { @@ -47,6 +50,8 @@ export type LaunchSessionResponse = { worktree_path?: string; worktree_branch?: string; error?: string; + activation_disposition?: "queued" | "suppressed"; + activation_reason?: string; }; export async function launchSession( @@ -70,9 +75,13 @@ export type EnsureSessionResponse = { | "existing_newest" | "created_prepare" | "created_start" - | "skipped_terminal_pr"; + | "skipped_terminal_pr" + | "queued" + | "existing_queued"; newly_created: boolean; workspace_path?: string; + activation_disposition?: "queued" | "suppressed"; + activation_reason?: string; }; /** @@ -84,7 +93,12 @@ export type EnsureSessionResponse = { */ export async function ensureTaskSession( taskId: string, - opts?: { ensureExecution?: boolean; autoStart?: boolean; timeout?: number }, + opts?: { + ensureExecution?: boolean; + autoStart?: boolean; + activationSource?: LaunchActivationSource; + timeout?: number; + }, ): Promise { const client = getWebSocketClient(); if (!client) throw new Error("WebSocket client not available"); @@ -94,6 +108,7 @@ export async function ensureTaskSession( task_id: taskId, ensure_execution: opts?.ensureExecution, ...(opts?.autoStart !== undefined ? { auto_start: opts.autoStart } : {}), + ...(opts?.activationSource !== undefined ? { activation_source: opts.activationSource } : {}), }, opts?.timeout ?? 15_000, ); diff --git a/apps/web/lib/tasks/launch-queue-view-model.test.ts b/apps/web/lib/tasks/launch-queue-view-model.test.ts new file mode 100644 index 0000000000..6183ecfcb1 --- /dev/null +++ b/apps/web/lib/tasks/launch-queue-view-model.test.ts @@ -0,0 +1,102 @@ +/* eslint-disable sonarjs/no-duplicate-string -- Repeated wire fixtures assert identity across freshness states. */ +import { describe, expect, it } from "vitest"; +import { buildLaunchQueueViewModel } from "./launch-queue-view-model"; + +const queuedAt = "2026-09-16T20:15:44Z"; + +describe("buildLaunchQueueViewModel", () => { + it("keeps the exact destination and capacity sample without adding queue position", () => { + expect( + buildLaunchQueueViewModel( + { + session_id: "luna-session", + agent_profile_id: "luna-profile", + workflow_step_id: "implement", + queued_at: queuedAt, + reason: "session_capacity", + retrying: true, + capacity: { in_use: 5, limit: 5, observed_at: queuedAt }, + }, + { now: Date.parse(queuedAt) + 1_000 }, + ), + ).toEqual({ + destinationId: "luna-profile", + queuedAt, + reason: "session_capacity", + retrying: true, + capacityFreshness: "current", + capacity: { inUse: 5, limit: 5, observedAt: queuedAt }, + }); + }); + + it("marks capacity data stale after two sweep intervals", () => { + expect( + buildLaunchQueueViewModel( + { + session_id: "luna-session", + queued_at: queuedAt, + reason: "session_capacity", + retrying: true, + capacity: { in_use: 5, limit: 5, observed_at: queuedAt }, + }, + { now: Date.parse(queuedAt) + 40_001 }, + ), + ).toMatchObject({ capacityFreshness: "stale" }); + }); + + it("treats the exact freshness boundary as stale", () => { + expect( + buildLaunchQueueViewModel( + { + session_id: "luna-session", + queued_at: queuedAt, + reason: "session_capacity", + retrying: true, + capacity: { in_use: 5, limit: 5, observed_at: queuedAt }, + }, + { now: Date.parse(queuedAt) + 40_000 }, + ), + ).toMatchObject({ capacityFreshness: "stale" }); + }); + + it("marks known capacity stale while the client is disconnected", () => { + expect( + buildLaunchQueueViewModel( + { + session_id: "luna-session", + queued_at: queuedAt, + reason: "session_capacity", + retrying: true, + capacity: { in_use: 5, limit: 5, observed_at: queuedAt }, + }, + { now: Date.parse(queuedAt), isConnected: false }, + ), + ).toMatchObject({ capacityFreshness: "stale" }); + }); + + it("preserves an unknown capacity instead of inventing an estimate", () => { + expect( + buildLaunchQueueViewModel({ + session_id: "luna-session", + queued_at: queuedAt, + reason: "ownership_unavailable", + retrying: true, + }), + ).toMatchObject({ + destinationId: null, + capacityFreshness: "unavailable", + capacity: null, + }); + }); + + it("rejects incomplete or unknown wire values", () => { + expect(buildLaunchQueueViewModel(undefined)).toBeNull(); + expect( + buildLaunchQueueViewModel({ + queued_at: queuedAt, + reason: "not-a-reason" as "replay_error", + retrying: true, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/lib/tasks/launch-queue-view-model.ts b/apps/web/lib/tasks/launch-queue-view-model.ts new file mode 100644 index 0000000000..a9802cf3f8 --- /dev/null +++ b/apps/web/lib/tasks/launch-queue-view-model.ts @@ -0,0 +1,73 @@ +import type { TaskStatusSummaryLaunchQueue } from "@/lib/types/task-status-summary"; + +export type LaunchQueueCapacityFreshness = "current" | "stale" | "unavailable"; + +export type LaunchQueueViewModel = { + destinationId: string | null; + queuedAt: string; + reason: TaskStatusSummaryLaunchQueue["reason"]; + retrying: boolean; + capacityFreshness: LaunchQueueCapacityFreshness; + capacity: { + inUse: number; + limit: number; + observedAt: string; + } | null; +}; + +const LAUNCH_QUEUE_REASONS = new Set([ + "session_capacity", + "ownership_unavailable", + "replay_error", +]); + +export const LAUNCH_QUEUE_CAPACITY_STALE_AFTER_MS = 40_000; + +function validCapacity( + capacity: TaskStatusSummaryLaunchQueue["capacity"], +): LaunchQueueViewModel["capacity"] { + if (!capacity || !capacity.observed_at) return null; + if ( + !Number.isInteger(capacity.in_use) || + !Number.isInteger(capacity.limit) || + capacity.in_use < 0 || + capacity.limit < 0 + ) { + return null; + } + if (!Number.isFinite(Date.parse(capacity.observed_at))) return null; + return { + inUse: capacity.in_use, + limit: capacity.limit, + observedAt: capacity.observed_at, + }; +} + +/** Convert the wire summary into the small, presentation-independent queue model. */ +export function buildLaunchQueueViewModel( + queue: TaskStatusSummaryLaunchQueue | null | undefined, + options: { now?: number; isConnected?: boolean } = {}, +): LaunchQueueViewModel | null { + if (!queue || !queue.queued_at || !LAUNCH_QUEUE_REASONS.has(queue.reason)) return null; + const capacity = validCapacity(queue.capacity); + const now = options.now ?? Date.now(); + const isConnected = options.isConnected ?? true; + let capacityFreshness: LaunchQueueCapacityFreshness = "unavailable"; + if (capacity) { + const observedAt = Date.parse(capacity.observedAt); + const age = now - observedAt; + capacityFreshness = + !isConnected || age >= LAUNCH_QUEUE_CAPACITY_STALE_AFTER_MS ? "stale" : "current"; + } + return { + // Session IDs are ownership keys, not user-facing destination labels. A + // missing profile projection must fall back to the translated generic + // label instead of exposing an opaque UUID. + destinationId: queue.agent_profile_id?.trim() || null, + queuedAt: queue.queued_at, + reason: queue.reason, + retrying: queue.retrying, + capacityFreshness, + capacity, + }; +} diff --git a/apps/web/lib/types/task-status-summary.ts b/apps/web/lib/types/task-status-summary.ts index 548fd54d10..e37e358eb6 100644 --- a/apps/web/lib/types/task-status-summary.ts +++ b/apps/web/lib/types/task-status-summary.ts @@ -23,6 +23,20 @@ export type TaskStatusSummaryActiveError = { recovery_actions?: TaskLaunchRecoveryAction[]; }; +export type TaskStatusSummaryLaunchQueue = { + session_id?: string; + agent_profile_id?: string; + workflow_step_id?: string; + queued_at: string; + reason: "session_capacity" | "ownership_unavailable" | "replay_error"; + retrying: boolean; + capacity?: { + in_use: number; + limit: number; + observed_at: string; + }; +}; + export type TaskStatusSummary = { revision: number; updated_at: string; @@ -37,6 +51,8 @@ export type TaskStatusSummary = { pending_action?: TaskPendingAction; /** Number of prompts currently en-queued for the task (all sessions). */ queued_prompt_count?: number; + /** Automatic session launch waiting for admission, independent of the selected session. */ + launch_queue?: TaskStatusSummaryLaunchQueue | null; active_error?: TaskStatusSummaryActiveError | null; /** Current task-owned failure, independent of the selected session. */ task_error?: TaskStatusSummaryActiveError | null; diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index c228465ed3..dfb5d87af0 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -1272,6 +1272,25 @@ "queueAdmissionValidation": "The message content or attachments are invalid.", "queueMoreInstructions": "Queue more instructions...", "queued": "Queued", + "launchQueueTitle": "Automatic launch", + "launchQueueLabel": "Queued", + "launchQueueIndicator": "Automatic launch queued", + "launchQueueDestination": "Destination: {{destination}}", + "launchQueueWaitingCapacity": "Waiting for session capacity.", + "launchQueueCapacity": "{{inUse}} of {{limit}} sessions in use. Checked {{checkedAt}}.", + "launchQueueCapacityStale": "{{inUse}} of {{limit}} sessions in use. Capacity data is stale. Last checked {{checkedAt}}.", + "launchQueueCapacityDisconnected": "{{inUse}} of {{limit}} sessions in use. Capacity connection is unavailable. Last checked {{checkedAt}}.", + "launchQueueCapacityUnavailable": "Capacity is unavailable. Kandev will retry automatically.", + "launchQueueCapacityUnavailableStopped": "Capacity information is unavailable.", + "launchQueueSince": "Queued since {{time}}.", + "launchQueueAutomaticRetry": "Kandev will retry automatically when capacity is available.", + "launchQueueRetryPending": "Retry pending. Kandev will keep the queued launch.", + "launchQueueRetryStopped": "Automatic retry has stopped.", + "launchQueueOwnershipUnavailable": "Launch ownership is unavailable. No automatic launch was attempted.", + "launchQueueReplayError": "The queued launch needs attention. Kandev will retry it.", + "launchQueueReplayErrorStopped": "The queued launch needs attention.", + "launchQueueUnknownDestination": "the queued session", + "parkedSessionNote": "This session is parked for the workflow. Opening it does not resume it. Send a message to continue here.", "quorumAwaiting": "Awaiting {{received}} of {{required}} {{role}} decisions", "quorumStuck": "Stuck", "quorumStuckPendingReevaluation": "Blocked: no active session to re-evaluate this step", diff --git a/apps/web/src/locales/pseudo/agents.json b/apps/web/src/locales/pseudo/agents.json index 56ee639e50..39c6fa517f 100644 --- a/apps/web/src/locales/pseudo/agents.json +++ b/apps/web/src/locales/pseudo/agents.json @@ -317,5 +317,5 @@ "providerApiKeyLabel": "ÀƤĨ ķēŷ", "providerApiKeyHelp": "Ķàńďēv śēćŕēţ ĥōĺďĩńĝ ţĥē ƀēàŕēŕ ţōķēń śēńţ ţō ţĥē ƥŕōvĩďēŕ. Ĺēàvē ũńśēţ ƒōŕ à ŕōũţēŕ ţĥàţ ńēēďś ńō àũţĥ.", "providerModelSlash": "Ŕēḿōvē ţĥē śĺàśĥ ƒŕōḿ ţĥē ḿōďēĺ ńàḿē: àń ŌƥēńÀĨ-ćōḿƥàţĩƀĺē ƥŕōvĩďēŕ ēxƥēćţś à ƀàŕē ḿōďēĺ ĩď.", - "providerPassthroughUnsupported": "Đĩśàƀĺē ĆĹĨ ƥàśśţĥŕōũĝĥ ţō ũśē àń ŌƥēńÀĨ-ćōḿƥàţĩƀĺē ƥŕōvĩďēŕ." + "providerPassthroughUnsupported": "Ďĩśàƀĺē ĆĹĨ ƥàśśţĥŕōũĝĥ ţō ũśē àń ŌƥēńÀĨ-ćōḿƥàţĩƀĺē ƥŕōvĩďēŕ." } diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index a22e14ca07..0245d0ab6e 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -59,7 +59,7 @@ "requireExactModelHelper": "Ōńĺŷ śţàŕţ ţĥē śēśśĩōń ŵĥēń ţĥĩś ēxàćţ ḿōďēĺ ĩś àďvēŕţĩśēď àńď àƥƥĺĩēď. Śàvēď ƒàĺĺƀàćķ śēţţĩńĝś śţàŷ àvàĩĺàƀĺē ŵĥēń ŷōũ ţũŕń ţĥĩś ōƒƒ.", "requireExactModelInfoLabel": "Ēxƥĺàĩń ēxàćţ ḿōďēĺ ŕēqũĩŕēḿēńţ", "requireExactModelHelpTitle": "Ŕēqũĩŕē ēxàćţ ḿōďēĺ", - "requireExactModelHelp": "Ţĥē śēśśĩōń ƒàĩĺś ĩƒ ţĥē ćōńƒĩĝũŕēď ḿōďēĺ ĩś ńōţ àďvēŕţĩśēď ōŕ ćàńńōţ ƀē àƥƥĺĩēď. Àũţōḿàţĩć àńď ēxƥĺĩćĩţ ƒàĺĺƀàćķ śēţţĩńĝś śţàŷ śàvēď ƀũţ ĩńàćţĩvē.", + "requireExactModelHelp": "Ţĥē śēśśĩōń ƒàĩĺś ĩƒ ţĥē ćōńƒĩĝũŕēď ḿōďēĺ ĩś ńōţ àďvēŕţĩśēď ōŕ ćàńńōţ ƀē àƥƥĺĩēď. Àũţōḿàţĩć àńď ēxƥĺĩćĩţ ƒàĺĺƀàćķ śēţţĩńĝś śţàŷ śàvēď ƀũţ àŕē ĩńàćţĩvē.", "autoStartLanguageServer": "Àũţō-śţàŕţ {{language}} ĺàńĝũàĝē śēŕvēŕ", "builtIn": "Ɓũĩĺţ-ĩń", "builtInBrowserPanel": "Ɓũĩĺţ-ĩń ƀŕōŵśēŕ ƥàńēĺ", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 0df499041e..09b78fb141 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -975,7 +975,7 @@ "workflowMovePreviewDispatchPrompt": "Śēńď śţēƥ ƥŕōḿƥţ", "workflowMovePreviewDispatchNoPrompt": "Ďō ńōţ śēńď à ƥŕōḿƥţ", "workflowMovePreviewDispatchDeferred": "Ćĥēćķ àĝàĩń ŵĥēń ţĥē ţũŕń ēńďś", - "workflowMovePreviewDispatchNoSession": "Āĝēńţ ńōţ śţàŕţēď", + "workflowMovePreviewDispatchNoSession": "Àĝēńţ ńōţ śţàŕţēď", "workflowMovePreviewNotes": "Ńōţēś", "workflowMovePreviewNoticeRetainedOverride": "Ţĥē śēśśĩōń ķēēƥś ĩţś ḿōďēĺ ōvēŕŕĩďē.", "workflowMovePreviewNoticeUnavailable": "Śōḿē ƥŕēvĩēŵ śēţţĩńĝś àŕē ũńàvàĩĺàƀĺē.", @@ -1272,6 +1272,25 @@ "queueAdmissionValidation": "Ţĥē ḿēśśàĝē ćōńţēńţ ōŕ àţţàćĥḿēńţś àŕē ĩńvàĺĩď.", "queueMoreInstructions": "Qũēũē ḿōŕē ĩńśţŕũćţĩōńś...", "queued": "Qũēũēď", + "launchQueueTitle": "Àũţōḿàţĩć ĺàũńćĥ", + "launchQueueLabel": "Qũēũēď", + "launchQueueIndicator": "Àũţōḿàţĩć ĺàũńćĥ qũēũēď", + "launchQueueDestination": "Ďēśţĩńàţĩōń: {{destination}}", + "launchQueueWaitingCapacity": "Ŵàĩţĩńĝ ƒōŕ śēśśĩōń ćàƥàćĩţŷ.", + "launchQueueCapacity": "{{inUse}} ōƒ {{limit}} śēśśĩōńś ĩń ũśē. Ćĥēćķēď {{checkedAt}}.", + "launchQueueCapacityStale": "{{inUse}} ōƒ {{limit}} śēśśĩōńś ĩń ũśē. Ćàƥàćĩţŷ ďàţà ĩś śţàĺē. Ĺàśţ ćĥēćķēď {{checkedAt}}.", + "launchQueueCapacityDisconnected": "{{inUse}} ōƒ {{limit}} śēśśĩōńś ĩń ũśē. Ćàƥàćĩţŷ ćōńńēćţĩōń ĩś ũńàvàĩĺàƀĺē. Ĺàśţ ćĥēćķēď {{checkedAt}}.", + "launchQueueCapacityUnavailable": "Ćàƥàćĩţŷ ĩś ũńàvàĩĺàƀĺē. Ķàńďēv ŵĩĺĺ ŕēţŕŷ àũţōḿàţĩćàĺĺŷ.", + "launchQueueCapacityUnavailableStopped": "Ćàƥàćĩţŷ ĩńƒōŕḿàţĩōń ĩś ũńàvàĩĺàƀĺē.", + "launchQueueSince": "Qũēũēď śĩńćē {{time}}.", + "launchQueueAutomaticRetry": "Ķàńďēv ŵĩĺĺ ŕēţŕŷ àũţōḿàţĩćàĺĺŷ ŵĥēń ćàƥàćĩţŷ ĩś àvàĩĺàƀĺē.", + "launchQueueRetryPending": "Ŕēţŕŷ ƥēńďĩńĝ. Ķàńďēv ŵĩĺĺ ķēēƥ ţĥē qũēũēď ĺàũńćĥ.", + "launchQueueRetryStopped": "Àũţōḿàţĩć ŕēţŕŷ ĥàś śţōƥƥēď.", + "launchQueueOwnershipUnavailable": "Ĺàũńćĥ ōŵńēŕśĥĩƥ ĩś ũńàvàĩĺàƀĺē. Ńō àũţōḿàţĩć ĺàũńćĥ ŵàś àţţēḿƥţēď.", + "launchQueueReplayError": "Ţĥē qũēũēď ĺàũńćĥ ńēēďś àţţēńţĩōń. Ķàńďēv ŵĩĺĺ ŕēţŕŷ ĩţ.", + "launchQueueReplayErrorStopped": "Ţĥē qũēũēď ĺàũńćĥ ńēēďś àţţēńţĩōń.", + "launchQueueUnknownDestination": "ţĥē qũēũēď śēśśĩōń", + "parkedSessionNote": "Ţĥĩś śēśśĩōń ĩś ƥàŕķēď ƒōŕ ţĥē ŵōŕķƒĺōŵ. Ōƥēńĩńĝ ĩţ ďōēś ńōţ ŕēśũḿē ĩţ. Śēńď à ḿēśśàĝē ţō ćōńţĩńũē ĥēŕē.", "quorumAwaiting": "Àŵàĩţĩńĝ {{received}} ōƒ {{required}} {{role}} ďēćĩśĩōńś", "quorumStuck": "Śţũćķ", "quorumStuckPendingReevaluation": "Ɓĺōćķēď: ńō àćţĩvē śēśśĩōń ţō ŕē-ēvàĺũàţē ţĥĩś śţēƥ", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index d18a8d9e5d..27c14e8769 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -1215,6 +1215,25 @@ "queueAdmissionValidation": "O conteúdo da mensagem ou os anexos são inválidos.", "queueMoreInstructions": "Colocar mais instruções em fila...", "queued": "Em fila", + "launchQueueTitle": "Arranque automático", + "launchQueueLabel": "Em fila", + "launchQueueIndicator": "Arranque automático em fila", + "launchQueueDestination": "Destino: {{destination}}", + "launchQueueWaitingCapacity": "A aguardar capacidade de sessão.", + "launchQueueCapacity": "{{inUse}} de {{limit}} sessões em uso. Verificado {{checkedAt}}.", + "launchQueueCapacityStale": "{{inUse}} de {{limit}} sessões em uso. Os dados de capacidade estão desatualizados. Verificado {{checkedAt}}.", + "launchQueueCapacityDisconnected": "{{inUse}} de {{limit}} sessões em uso. A ligação à capacidade não está disponível. Última verificação {{checkedAt}}.", + "launchQueueCapacityUnavailable": "A capacidade não está disponível. O Kandev vai tentar novamente automaticamente.", + "launchQueueCapacityUnavailableStopped": "As informações de capacidade não estão disponíveis.", + "launchQueueSince": "Em fila desde {{time}}.", + "launchQueueAutomaticRetry": "O Kandev vai tentar novamente quando houver capacidade disponível.", + "launchQueueRetryPending": "Nova tentativa pendente. O Kandev vai manter o arranque em fila.", + "launchQueueRetryStopped": "As tentativas automáticas pararam.", + "launchQueueOwnershipUnavailable": "Não foi possível confirmar o proprietário do arranque. O arranque automático não foi executado.", + "launchQueueReplayError": "O arranque em fila precisa de atenção. O Kandev vai tentar novamente.", + "launchQueueReplayErrorStopped": "O arranque em fila precisa de atenção.", + "launchQueueUnknownDestination": "a sessão em fila", + "parkedSessionNote": "Esta sessão está parada para o fluxo de trabalho. Abri-la não a retoma. Envie uma mensagem para continuar aqui.", "quorumAwaiting": "A aguardar {{received}} de {{required}} decisões de {{role}}", "quorumStuck": "Bloqueado", "quorumStuckPendingReevaluation": "Bloqueado: sem sessão ativa para reavaliar esta etapa", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index b7cceb858c..daeec7eb3e 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -1214,6 +1214,25 @@ "queueAdmissionValidation": "消息内容或附件无效。", "queueMoreInstructions": "排队更多指示…", "queued": "已排队", + "launchQueueTitle": "自动启动", + "launchQueueLabel": "已排队", + "launchQueueIndicator": "自动启动已排队", + "launchQueueDestination": "目标:{{destination}}", + "launchQueueWaitingCapacity": "正在等待会话容量。", + "launchQueueCapacity": "已使用 {{inUse}}/{{limit}} 个会话。检查时间:{{checkedAt}}。", + "launchQueueCapacityStale": "已使用 {{inUse}}/{{limit}} 个会话。容量数据已过期。上次检查:{{checkedAt}}。", + "launchQueueCapacityDisconnected": "已使用 {{inUse}}/{{limit}} 个会话。容量连接不可用。上次检查:{{checkedAt}}。", + "launchQueueCapacityUnavailable": "容量不可用。Kandev 会自动重试。", + "launchQueueCapacityUnavailableStopped": "容量信息不可用。", + "launchQueueSince": "排队时间:{{time}}。", + "launchQueueAutomaticRetry": "容量可用后,Kandev 会自动重试。", + "launchQueueRetryPending": "等待重试。Kandev 将保留此排队启动。", + "launchQueueRetryStopped": "自动重试已停止。", + "launchQueueOwnershipUnavailable": "无法确认启动归属。未执行自动启动。", + "launchQueueReplayError": "排队启动需要处理。Kandev 会重试。", + "launchQueueReplayErrorStopped": "排队启动需要处理。", + "launchQueueUnknownDestination": "排队的会话", + "parkedSessionNote": "此会话已为工作流暂停。打开它不会恢复会话。发送消息即可在此继续。", "quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 项决定", "quorumStuck": "受阻", "quorumStuckPendingReevaluation": "受阻:没有活动会话可重新评估此步骤", diff --git a/apps/web/src/locales/zh-hk/agents.json b/apps/web/src/locales/zh-hk/agents.json index 37daf8f9c6..b0d122d2b7 100644 --- a/apps/web/src/locales/zh-hk/agents.json +++ b/apps/web/src/locales/zh-hk/agents.json @@ -305,17 +305,17 @@ "dynamicRouteStopDescription": "此候選設定失敗後停止路由。任務會等待手動恢復操作。", "dynamicProfileSaved": "動態設定已儲存。", "adminOnly": "只有管理員可以更改代理和代理設定檔。", - "providerSectionTitle": "AI 供應方", - "providerSectionDescription": "讓此設定檔的模型呼叫透過你自建的 OpenAI 相容路由器,而不是代理程式內建的供應方。", - "providerKindLabel": "供應方", + "providerSectionTitle": "AI 提供方", + "providerSectionDescription": "讓該設定的模型呼叫通過你自建的 OpenAI 兼容路由器,而不是代理程式內置的提供方。", + "providerKindLabel": "提供方", "providerKindNative": "原生(代理程式預設)", - "providerKindOpenAICompatible": "OpenAI 相容供應方", - "providerKindHelp": "原生會保留代理程式 CLI 本身的供應方設定。OpenAI 相容會使用所選金鑰向下方的基礎 URL 送出請求,可搭配 9router、LiteLLM、vLLM 以及自建的 OpenRouter 使用。", + "providerKindOpenAICompatible": "OpenAI 兼容提供方", + "providerKindHelp": "原生會保留代理程式 CLI 自身的提供方設定。OpenAI 兼容會使用所選密鑰向下面的基礎 URL 傳送請求,可配合 9router、LiteLLM、vLLM 以及自建的 OpenRouter 使用。", "providerBaseUrlLabel": "基礎 URL", - "providerBaseUrlHelp": "你的路由器的絕對 http 或 https 根位址。代理程式會附加 OpenAI 相容的路由路徑,例如 /chat/completions。", + "providerBaseUrlHelp": "你的路由器的絕對 http 或 https 根地址。代理程式會附加 OpenAI 兼容的路由路徑,例如 /chat/completions。", "providerBaseUrlInvalid": "請輸入絕對的 http 或 https URL。", - "providerApiKeyLabel": "API 金鑰", - "providerApiKeyHelp": "保存傳送給供應方的持有者權杖的 Kandev 密鑰。若路由器不需驗證,可不設定。", - "providerModelSlash": "請移除模型名稱中的斜線:OpenAI 相容供應方需要的是不含前綴的模型 ID。", - "providerPassthroughUnsupported": "請停用 CLI 直通模式,才能使用 OpenAI 相容供應方。" + "providerApiKeyLabel": "API 密鑰", + "providerApiKeyHelp": "儲存傳送給提供方的持有者權杖的 Kandev 密鑰。若路由器無需鑑權,可不設定。", + "providerModelSlash": "請去掉模型名稱中的斜槓:OpenAI 兼容提供方需要的是不含前綴的模型 ID。", + "providerPassthroughUnsupported": "請停用 CLI 直通模式,才能使用 OpenAI 兼容提供方。" } diff --git a/apps/web/src/locales/zh-hk/settings.json b/apps/web/src/locales/zh-hk/settings.json index f19c7837d6..c64cbb982e 100644 --- a/apps/web/src/locales/zh-hk/settings.json +++ b/apps/web/src/locales/zh-hk/settings.json @@ -56,10 +56,10 @@ "autoFallbackHelpTitle": "自動後備", "autoFallbackInfoLabel": "說明自動後備", "requireExactModel": "要求精確模型", - "requireExactModelHelper": "只有在此精確模型已公佈並成功套用時才啟動工作階段。關閉此選項後,已儲存的後備設定仍可使用。", + "requireExactModelHelper": "僅當此精確模型已公佈併成功應用時才啓動工作階段。關閉此選項後,已儲存的後備設定仍可使用。", "requireExactModelInfoLabel": "說明精確模型要求", "requireExactModelHelpTitle": "要求精確模型", - "requireExactModelHelp": "如果設定的模型未公佈或無法套用,工作階段會失敗。自動及明確後備設定會保留,但不會生效。", + "requireExactModelHelp": "如果設定的模型未公佈或無法應用,工作階段將失敗。自動和明確指定的後備設定會保留,但不會生效。", "autoStartLanguageServer": "自動啓動 {{language}} 語言伺服器", "builtIn": "內置", "builtInBrowserPanel": "內置瀏覽器面板", @@ -345,7 +345,7 @@ "fallbackSettingsSummaryExact": "已要求精確模型", "fallbackSettingsSummaryExecutorDefault": "允許使用執行器預設模型", "requireExactModelValidationTitle": "需要模型", - "requireExactModelValidation": "啟用精確模型要求前,請選擇一個具體模型。", + "requireExactModelValidation": "啓用精確模型要求前,請選擇一個具體模型。", "fallbackNoteLive": "此工作階段因設定的模型不可用而以備用模型啓動。(備用)標記僅反映目前工作階段,重新載入後不再顯示。", "modelFallbackSuffix": "(備用)", "globalSecrets": "全局密鑰", diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 9b72ee6177..54e9fd8bfc 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -1214,6 +1214,25 @@ "queueAdmissionValidation": "訊息內容或附件無效。", "queueMoreInstructions": "排隊更多指示…", "queued": "已排隊", + "launchQueueTitle": "自動啓動", + "launchQueueLabel": "已排隊", + "launchQueueIndicator": "自動啓動已排隊", + "launchQueueDestination": "目標:{{destination}}", + "launchQueueWaitingCapacity": "正在等待工作階段容量。", + "launchQueueCapacity": "已使用 {{inUse}}/{{limit}} 個工作階段。檢查時間:{{checkedAt}}。", + "launchQueueCapacityStale": "已使用 {{inUse}}/{{limit}} 個工作階段。容量數據已過期。上次檢查:{{checkedAt}}。", + "launchQueueCapacityDisconnected": "已使用 {{inUse}}/{{limit}} 個工作階段。容量連接不可用。上次檢查:{{checkedAt}}。", + "launchQueueCapacityUnavailable": "容量不可用。Kandev 會自動重試。", + "launchQueueCapacityUnavailableStopped": "容量資訊不可用。", + "launchQueueSince": "排隊時間:{{time}}。", + "launchQueueAutomaticRetry": "容量可用後,Kandev 會自動重試。", + "launchQueueRetryPending": "等待重試。Kandev 將保留此排隊啓動。", + "launchQueueRetryStopped": "自動重試已停止。", + "launchQueueOwnershipUnavailable": "無法確認啓動歸屬。未執行自動啓動。", + "launchQueueReplayError": "排隊啓動需要處理。Kandev 會重試。", + "launchQueueReplayErrorStopped": "排隊啓動需要處理。", + "launchQueueUnknownDestination": "排隊的工作階段", + "parkedSessionNote": "此工作階段已為工作流程暫停。開啟它不會恢復工作階段。傳送訊息即可在此繼續。", "quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 項決定", "quorumStuck": "受阻", "quorumStuckPendingReevaluation": "受阻:沒有活動工作階段可重新評估此步驟", diff --git a/apps/web/src/locales/zh-tw/agents.json b/apps/web/src/locales/zh-tw/agents.json index b00a50ac5c..8c0fe0eb0d 100644 --- a/apps/web/src/locales/zh-tw/agents.json +++ b/apps/web/src/locales/zh-tw/agents.json @@ -305,17 +305,17 @@ "dynamicRouteStopDescription": "此候選設定失敗後停止路由。任務會等待手動恢復操作。", "dynamicProfileSaved": "動態設定已儲存。", "adminOnly": "只有管理員可以更改代理和代理設定檔。", - "providerSectionTitle": "AI 供應方", - "providerSectionDescription": "讓此設定檔的模型呼叫透過你自建的 OpenAI 相容路由器,而不是代理程式內建的供應方。", - "providerKindLabel": "供應方", + "providerSectionTitle": "AI 提供方", + "providerSectionDescription": "讓該設定的模型呼叫通過你自建的 OpenAI 相容路由器,而不是代理程式內建的提供方。", + "providerKindLabel": "提供方", "providerKindNative": "原生(代理程式預設)", - "providerKindOpenAICompatible": "OpenAI 相容供應方", - "providerKindHelp": "原生會保留代理程式 CLI 本身的供應方設定。OpenAI 相容會使用所選金鑰向下方的基礎 URL 送出請求,可搭配 9router、LiteLLM、vLLM 以及自建的 OpenRouter 使用。", + "providerKindOpenAICompatible": "OpenAI 相容提供方", + "providerKindHelp": "原生會保留代理程式 CLI 自身的提供方設定。OpenAI 相容會使用所選金鑰向下面的基礎 URL 傳送請求,可配合 9router、LiteLLM、vLLM 以及自建的 OpenRouter 使用。", "providerBaseUrlLabel": "基礎 URL", - "providerBaseUrlHelp": "你的路由器的絕對 http 或 https 根位址。代理程式會附加 OpenAI 相容的路由路徑,例如 /chat/completions。", + "providerBaseUrlHelp": "你的路由器的絕對 http 或 https 根地址。代理程式會附加 OpenAI 相容的路由路徑,例如 /chat/completions。", "providerBaseUrlInvalid": "請輸入絕對的 http 或 https URL。", "providerApiKeyLabel": "API 金鑰", - "providerApiKeyHelp": "保存傳送給供應方的持有者權杖的 Kandev 密鑰。若路由器不需驗證,可不設定。", - "providerModelSlash": "請移除模型名稱中的斜線:OpenAI 相容供應方需要的是不含前綴的模型 ID。", - "providerPassthroughUnsupported": "請停用 CLI 直通模式,才能使用 OpenAI 相容供應方。" + "providerApiKeyHelp": "儲存傳送給提供方的持有者權杖的 Kandev 金鑰。若路由器無需鑑權,可不設定。", + "providerModelSlash": "請去掉模型名稱中的斜槓:OpenAI 相容提供方需要的是不含字首的模型 ID。", + "providerPassthroughUnsupported": "請停用 CLI 直通模式,才能使用 OpenAI 相容提供方。" } diff --git a/apps/web/src/locales/zh-tw/settings.json b/apps/web/src/locales/zh-tw/settings.json index 316c48d585..e0612d49a3 100644 --- a/apps/web/src/locales/zh-tw/settings.json +++ b/apps/web/src/locales/zh-tw/settings.json @@ -56,10 +56,10 @@ "autoFallbackHelpTitle": "自動備援", "autoFallbackInfoLabel": "說明自動備援", "requireExactModel": "要求精確模型", - "requireExactModelHelper": "只有在此精確模型已公布並成功套用時才啟動工作階段。關閉此選項後,已儲存的備援設定仍可使用。", + "requireExactModelHelper": "僅當此精確模型已公佈並成功套用時才啟動工作階段。關閉此選項後,已儲存的備援設定仍可使用。", "requireExactModelInfoLabel": "說明精確模型要求", "requireExactModelHelpTitle": "要求精確模型", - "requireExactModelHelp": "如果設定的模型未公布或無法套用,工作階段會失敗。自動及明確備援設定會保留,但不會生效。", + "requireExactModelHelp": "如果設定的模型未公佈或無法應用,工作階段將失敗。自動和明確指定的備援設定會保留,但不會生效。", "autoStartLanguageServer": "自動啟動 {{language}} 語言伺服器", "builtIn": "內建", "builtInBrowserPanel": "內建瀏覽器面板", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 301c005010..77ecb100cb 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -1214,6 +1214,25 @@ "queueAdmissionValidation": "訊息內容或附件無效。", "queueMoreInstructions": "排隊更多指示…", "queued": "已排隊", + "launchQueueTitle": "自動啟動", + "launchQueueLabel": "已排隊", + "launchQueueIndicator": "自動啟動已排隊", + "launchQueueDestination": "目標:{{destination}}", + "launchQueueWaitingCapacity": "正在等待工作階段容量。", + "launchQueueCapacity": "已使用 {{inUse}}/{{limit}} 個工作階段。檢查時間:{{checkedAt}}。", + "launchQueueCapacityStale": "已使用 {{inUse}}/{{limit}} 個工作階段。容量資料已過期。上次檢查:{{checkedAt}}。", + "launchQueueCapacityDisconnected": "已使用 {{inUse}}/{{limit}} 個工作階段。容量連線不可用。上次檢查:{{checkedAt}}。", + "launchQueueCapacityUnavailable": "容量不可用。Kandev 會自動重試。", + "launchQueueCapacityUnavailableStopped": "容量資訊不可用。", + "launchQueueSince": "排隊時間:{{time}}。", + "launchQueueAutomaticRetry": "容量可用後,Kandev 會自動重試。", + "launchQueueRetryPending": "等待重試。Kandev 將保留此排隊啟動。", + "launchQueueRetryStopped": "自動重試已停止。", + "launchQueueOwnershipUnavailable": "無法確認啟動歸屬。未執行自動啟動。", + "launchQueueReplayError": "排隊啟動需要處理。Kandev 會重試。", + "launchQueueReplayErrorStopped": "排隊啟動需要處理。", + "launchQueueUnknownDestination": "排隊的工作階段", + "parkedSessionNote": "此工作階段已為工作流程暫停。開啟它不會恢復工作階段。傳送訊息即可在此繼續。", "quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 項決定", "quorumStuck": "受阻", "quorumStuckPendingReevaluation": "受阻:沒有活動工作階段可重新評估此步驟", diff --git a/docs/decisions/2026-09-16-passive-session-inspection.md b/docs/decisions/2026-09-16-passive-session-inspection.md new file mode 100644 index 0000000000..8bb2ee47d2 --- /dev/null +++ b/docs/decisions/2026-09-16-passive-session-inspection.md @@ -0,0 +1,54 @@ +# ADR-2026-09-16-passive-session-inspection: Keep inspection separate from execution ownership + +**Status:** proposed +**Date:** 2026-09-16 +**Area:** workflow + +## Context + +Workflow parking and process loss both appear as resumable sessions without a +running agent. Treating these cases alike lets opening an old conversation wake +it. The same resume request currently receives manual ceiling privileges. +A sibling ready event can then reconcile a queued task to Review. + +## Decision + +Passive inspection carries explicit intent across the client/server boundary. +The server verifies workflow parking and queued ownership before launch side +effects. Workflow parking is durable policy state, separate from execution-stamped +stop-event suppression and from the background-work projection. + +The exact workflow entry and destination own the deferred launch. The visible +conversation does not own it. Task reconciliation considers valid deferred work +as well as running sessions. Queue UI consumes a bounded task-status projection, +not transcript text or a second client queue. + +Explicit user execution remains possible and retains current manual admission. +It does not transfer workflow ownership from the queued recipient. A valid +workflow re-entry can release parking for its selected recipient. + +## Consequences + +The implementation adds a durable parking marker and explicit inspection +semantics. Old tasks need conservative recognition of existing park provenance. +The browser must propagate inspection intent through recovery fallbacks. +Existing manual callers remain compatible; this is not a new authorization grant. + +Counts in queue details are observations with freshness, not queue positions. +Task and summary revisions must prevent a delayed update from undoing a clear. + +## Alternatives considered + +- Disable all automatic recovery: changes unrelated recovery workflows and the + existing user preference. Suppress only ineligible passive execution. +- Mark predecessors Completed: loses the deliberate nonterminal park/reuse behavior. +- Treat any stop-intent tombstone as permanent parking: a tombstone must survive + legitimate reuse to suppress delayed callbacks, so it cannot own current policy. +- Add a hard limit to every manual launch: changes the existing admission contract. +- Repair only the sidebar spinner: leaves unintended execution and wrong task state intact. + +## Related records + +- [Requirements](../specs/tasks/requirements/queued-session-ownership.md) +- [Design](../specs/tasks/system-design/queued-session-ownership.md) +- [Workflow lifecycle decision](2026-08-31-workflow-profile-session-switch-policy.md) diff --git a/docs/plans/queued-session-ownership/plan.md b/docs/plans/queued-session-ownership/plan.md new file mode 100644 index 0000000000..2bb1c173d8 --- /dev/null +++ b/docs/plans/queued-session-ownership/plan.md @@ -0,0 +1,288 @@ +--- +created: 2026-09-16 +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: [] +--- + +# Implementation plan: Queued session ownership + +## Overview + +Keep Luna queued when a workflow enters Implement, keep the parked Astra +conversation untouched during inspection, and make waiting visible on desktop +and phone. Deliver three sequential outcomes: inspection safety, queue lifecycle +correctness, then a complete queue projection and UI. + +The task system owns this package because the deferred workflow entry owns its +recipient and state. Agent capacity and workflow WIP policy remain separate. +Read the [requirements](../../specs/tasks/requirements/queued-session-ownership.md), +[design](../../specs/tasks/system-design/queued-session-ownership.md), and +[decision](../../decisions/2026-09-16-passive-session-inspection.md). + +## Evidence and conformance + +Read-only investigation of task `ef15115f-12b9-4f8e-ba9f-bc0cd7e01bd8`, using +stored conversations, session lists, and backend build `1293d65a8e` (dirty): + +| Lisbon time, 2026-09-16 | Observed event | +| --- | --- | +| 21:15:44 | Manual move to Implement selects new primary Luna session `c7451c7d-8468-4b45-8c67-ef6427390c01`; task becomes SCHEDULING. | +| 21:15:48 | Astra `8bc0ec46-6aef-43a5-b8f2-56c64cd680d0` is parked; Luna automatic launch is deferred at 5/5. | +| 21:19:02 | Task open/focus and status checks precede Astra resume by about 135 ms. Resume is classified manual and admitted at 8/5. | +| 21:19:06 | Astra boot-ready changes it to WAITING_FOR_INPUT, then changes the task from SCHEDULING to REVIEW. Luna remains CREATED. | +| Through 21:23:00 | The scheduler continues retrying Luna every 20 seconds; its accepted queue entry survives. | + +The backend evidence does not prove a scheduler prompt to Astra. It shows a +resume through the shared launch path. Timing and frontend source support +open-time resumption; the exact user click and empty-output warning event remain +unproven. Do not encode either as an established cause. + +The original diagnostic archive is task-local, not a required implementation +input: `.kandev/diagnostics/c79c09e35c0b6b608246d9280e0d220f.zip`. Its manifest +reports truncation of older logs; the incident window was present. + +Confirmed defects are `buildResumeRequest` sharing manual admission with passive +recovery, and `writeTaskReviewState` ignoring a CREATED deferred destination. +Existing lifecycle AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.4 defines parking but +does not explicitly exclude inspection. The new requirement pair defines that +gap and queue visibility. Manual overrides remain allowed by +AC-AGENTS-SESSION-CEILING-001.2. + +## Scope + +### In scope + +- Durable workflow parking, explicit passive intent, guarded status/ensure/resume. +- Exact queued recipient and entry identity; state reconciliation and replay races. +- Revisioned queue details in sidebar, task details, and phone navigation. +- Regression tests, restart evidence, localization, and user documentation. + +### Out of scope + +- Hard limits for explicit manual launches, new limits/settings, queue ordering or ETA. +- A global queue page, Office scheduling, provider policy, or new session states. +- Changes to source/destination profile selection or workflow WIP admission. +- Rewriting historical transcript warnings or assuming the unexplained warning's cause. + +## Technical approach + +Task 01 adds parking metadata and passive-source handling end to end, including +status/launch/ensure eligibility and the browser recovery hook. Task 02 binds +workflow deferrals to their entry and guards state and clear operations. Task 03 +extends `task/statussummary` and delivers all queue surfaces together. + +No new SQL column or scheduler is planned. Conditional operations on existing +metadata and task state must be tested through both supported database contracts. +Never clear callback tombstones to implement unpark. Never use background-work +parking as workflow parking. Existing old records need conservative migration +or passive suppression, not guessed ownership. + +## ASCII UI preview + +### UI-01: Desktop task row and detail, queued destination + +Entry: move to Implement, then reopen the task or select Astra. + +```text +Before (observed) After +Review Scheduling + Investigate issue [clock] Investigate issue Queued + ++-------------------------------------------------------------------+ +| Investigate issue Implement | +| Queued: Luna. Waiting for session capacity | +| 5 of 5 in use. Checked just now. Queued since 21:15 | +| Starts automatically when capacity is available. | +| [Astra] [Luna: Queued] [Plan] | +| Parked for workflow. Send a message to continue this conversation. | +| Astra conversation ... | ++-------------------------------------------------------------------+ +``` + +The task queue region is outside conversation scroll and remains when Astra is +selected. The parked note is conversation-specific. Selecting Luna removes +the parked note but keeps queue status; it does not show an unsolicited Start +or Resume prompt. Copy and spacing are illustrative; placement and identity are required. + +### UI-02: Phone task navigation and detail + +```text +Task navigator (existing bottom drawer) ++------------------------------------+ +| Tasks Close| +| [clock] Investigate issue Queued | <- scrolling task list ++------------------------------------+ + +Task detail (direct navigation) ++------------------------------------+ +| Investigate issue Implement | +| Queued: Luna | +| Waiting for session capacity | +| 5 of 5. Checked just now. | +| Queued since 21:15 | +| Starts automatically. | +| [Astra v] | <- existing session picker +| Parked for workflow. | +| Send a message to continue here. | +| Conversation ... | <- single chat scroll owner +| Composer | +| Chat | Plan | Changes | More | <- existing safe-area nav ++------------------------------------+ +``` + +Reuse `SessionTaskSwitcherSheet`, `TaskSwitcherDrawer`, `MobileSessionsPicker`, +and the dedicated mobile layout. No stacked desktop panes or extra disclosure +drawer. Required queue text is visible without hover; existing navigation owns +touch/focus behavior. New interactive targets, if needed, are at least 44 px on +phones/coarse pointers; ordinary desktop sizing remains unchanged. + +### UI-03: Freshness and settlement, both viewports + +```text +Loading: Queue status loading... (do not offer a duplicate launch) +Disconnected: Queued: Luna. Reconnecting. Capacity last checked 21:16. +Read failure: Queued: Luna. Capacity unavailable. Automatic retry pending. +Replay error: Queued: Luna. Launch retry pending. [existing error details] +Ambiguous: Queued launch needs recovery. [existing recovery surface] +Dispatched: Queue region removed; ordinary Luna Starting/Running status. +Dropped: Queue region removed; existing disposition/error remains visible. +No deferral: No queue region. Ordinary task behavior. +``` + +UI-01 through UI-03 cover requirement 003. Counts are timestamped observations, +not an ordinal queue position. Pending questions and real failures remain visible. +Desktop and phone share the view model, while phone uses stacked text. Status +changes use accessible text; frequent population updates are not live announcements. + +## Tests + +The following are planned tests, not tests run during package creation. AC suffixes +refer to `AC-TASKS-QUEUED-SESSION-OWNERSHIP-`. + +| Work order | Test file and planned cases | Criteria | +| --- | --- | --- | +| 01 | `orchestrator/queued_session_inspection_test.go`: `TestQueuedSessionInspection` (parked/queued, free/full, explicit, unknown); `TestWorkflowParking` (no runtime, consumed tombstone, newer stamp, re-entry) | 001.1-6 | +| 01 | `task/repository/sqlite/workflow_parking_test.go`: `TestWorkflowParking` (conditional writes and restart); backend service/turn and frontend recovery tests | 001.1, 001.4, 001.6; 003.7 | +| 02 | `orchestrator/queued_session_ownership_test.go`: `TestQueuedSessionOwnership` (sibling boot/idle/failure, explicit sibling run); `TestQueuedEntryReplay` (exact-once, restart, deleted/terminal/moved, newer-record CAS races) | 002.1-6 | +| 03 | `task/statussummary/projector_launch_queue_test.go`, `rebuild_launch_queue_test.go`; task service/DTO tests; `task-status-summary` handler and queue view-model tests | 003.1-5 | +| 03 | Desktop/mobile rendered specs below | 001.1-3; 002.1-4; 003.1-7 | + +Each work order must record behavioral RED before correction and all exact GREEN +commands. Existing test scaffolds are not evidence of this defect until exercised. + +## E2E tests + +Create `apps/web/e2e/tests/workflow/queued-session-ownership.spec.ts` (`chromium`) +and `mobile-queued-session-ownership.spec.ts` (`mobile-chrome`) with shared +`queued-session-ownership-helpers.ts`. Extend the desktop file from Task 01 +through Task 03 rather than duplicating the same lifecycle fixture. + +Use the isolated worker backend's `useEnv` for a ceiling of 1, with restoration +in `finally`. Start and settle a source session, occupy capacity using a separate +controllable mock task, then move through the topbar into a different profile's +auto-start step. Assert Luna exists and is queued before inspection. Capture +transport before opening the task, selecting Astra, reloading, and reconnecting. +Prove no Astra boot/prompt/queued-message additions and unchanged primary ownership. +Do not use helpers that click recovery or reload as part of an idle wait. + +Release only the fixture's capacity holder. Observe Luna's one prompt delivery, +queue removal, and untouched Astra. Add explicit Astra follow-up as a separate +case; it must leave Luna's queue intact. Use real service/repository tests for +deterministic restart and simultaneous CAS races. E2E restart supplements them +by checking visible restoration without manually reconstructing queue state. + +Phone tests enter through the task drawer, choose each session, inspect queue +details, and observe automatic dispatch. Assert no document horizontal overflow, +visible queue text at 393 px, long-name wrapping, and existing picker target +geometry. A genuine empty prompt retains its warning in unit tests; queue waiting +and passive navigation produce none in the rendered scenario. + +## Work orders + +- [in_progress] [Task 01: Preserve parked sessions during inspection](task-01-preserve-parked-sessions.md) +- [in_progress] [Task 02: Preserve queued entry lifecycle](task-02-preserve-queued-entry.md) +- [in_progress] [Task 03: Expose queue status across task surfaces](task-03-expose-queue-status.md) + +Implementation is complete for all three sequential slices. The work orders +remain in progress because the required PostgreSQL checks need +`KANDEV_TEST_POSTGRES_DSN`, which is not configured in this workspace. No +subagents are authorized by this plan. + +## Related delivery records + +[Workflow lifecycle](../workflow-profile-session-reuse/plan.md), +[explicit targeting](../workflow-session-targeting/plan.md), +[same-profile fresh sessions](../workflow-same-profile-new-session/plan.md), and +[session ceiling](../session-concurrency-ceiling/plan.md) are compatibility inputs. +Their results are historical and do not prove this intersection. This package +owns the new cases; it does not reopen completed work or change lifecycle defaults. + +## Documentation impact + +During Task 03, update `docs/public/tasks-and-workflows.md` (explanation) and +`docs/public/agents-and-profiles.md` only where they describe inspection/recovery. +Explain the distinction between WIP waiting and session-capacity waiting. +No public page is changed during this design turn. Preserve existing overview +and configuration defaults. Check root README/screenshot references for affected wording. + +## Verification results + +Design-package validation on 2026-09-16: + +- `python3 scripts/list-docs.py validate`: passed (282 decisions, 971 specifications). +- `python3 scripts/lint-spec-files.test.py`: passed (36 tests). +- `python3 scripts/lint-spec-files.py --all`: passed. +- Catalog queries for `queued-session-ownership` and `passive-session-inspection`: + discovered the new requirement, design, and decision. +- Work-order reference check: all three files reference existing requirement/AC + IDs and system-design paths, with pending status and verification sections. +- `git diff --check`: passed. +- `git status --short -- docs/plans/queued-session-ownership`: confirmed the new + untracked package; files are ready for review and have not been committed. + +Implementation and available rendered verification on 2026-09-17: + +- Task 01 inspection and recovery coverage passed with + `go test -race ./internal/orchestrator -run + 'TestQueuedSessionInspection|TestWorkflowParking|Test.*Resume|Test.*ProfileSwitch' + -count=1`, the repository parking test, the focused frontend recovery tests, + typecheck, the backend build, and the desktop queued-session scenario. +- Task 02 queue lifecycle coverage passed with the race-focused orchestrator + suite, SQLite deferred-entry and service suites, the created-session Send Now + test, the desktop queued-session and workflow-targeting scenarios, and the + stale-successor compare-and-set test. +- Task 03 projection and UI coverage passed with statussummary/DTO/service + tests, the race-focused launch-queue suite, 93 focused frontend tests, + frontend typecheck and lint, the desktop and mobile scenarios, and the + localized queue view-model/component tests. +- `make -C apps/backend build` passed. `pnpm run i18n:check`, + `pnpm run i18n:ratchet`, public-doc validation, specification validation, + and `git diff --check` passed. +- `cd apps/web && pnpm test` passed (2,140 files, 18,465 tests, 4 skipped). +- `make -C apps/backend test` exercised all backend packages and passed the + task-related packages, but the repository-wide command exits nonzero on + unrelated environment-sensitive existing tests in process probing, home + configuration discovery, launcher configuration/restart, and the Office + priority migration fixture. +- The scoped E2E sleep lint preview and sleep ratchet passed. The full + `lint:e2e-sleeps` audit still reports the checkout's unrelated baseline + violations, so it is not used as a clean repository gate. +- The required PostgreSQL checks were not run because + `KANDEV_TEST_POSTGRES_DSN` is unset. The PostgreSQL parking contract test is + present and skips without that environment value. Until an isolated DSN is + supplied, Tasks 01 through 03 remain in progress. + +## Risks + +- Callback tombstones outlive actual parking; using them alone blocks valid reuse. +- A stale REVIEW writer or replay clear can overwrite a newer accepted entry. +- Passive-source omission in a fallback can still give opening manual privileges. +- 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. 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 new file mode 100644 index 0000000000..c087a7924e --- /dev/null +++ b/docs/plans/queued-session-ownership/task-01-preserve-parked-sessions.md @@ -0,0 +1,145 @@ +--- +id: "01-preserve-parked-sessions" +title: "Preserve parked sessions during inspection" +status: in_progress +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-001 + - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-003 +acceptance_criteria: + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.1 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.2 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.3 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.4 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.5 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.6 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.7 +system_design: + - ../../specs/tasks/system-design/queued-session-ownership.md +--- + +# Task 01: Preserve parked sessions during inspection + +## Summary + +Make workflow parking durable and keep passive inspection separate from explicit +execution. Deliver the backend guard and browser caller changes together so +opening the queued task cannot wake Astra or create another launch. + +## In scope + +- Implement the design's stamped `workflow_parking` metadata, no-runtime case, + conservative legacy resolution, and conditional clear on legitimate activation. +- Add inspection source and status eligibility; guard launch and ensure before + side effects. Propagate no-execution outcomes through browser fallbacks. +- Preserve actual manual overrides, direct conversation follow-ups, workflow + re-entry, preference semantics for ordinary sessions, and existing permissions. +- Add backend/repository RED tests, hook tests, and the first desktop lifecycle + scenario in `queued-session-ownership.spec.ts` using the shared fixture. +- Verify passive suppression creates no turn or empty-output warning. Keep the + genuine empty-turn test; diagnose any failure rather than suppressing all notices. + +## Out of scope + +Queue UI markup belongs to Task 03. Task state/replay race repair belongs to +Task 02. Do not change background-work parking, lifecycle defaults, or Office. + +## Acceptance + +1. The full/free capacity and preference on/off matrix leaves parked Astra + untouched after open, tab selection, preview, reload, and reconnect, while + preserving Luna's accepted payload and queue time. +2. Explicit Astra execution targets Astra alone; valid workflow reuse succeeds. + Old tombstones and no-runtime parking work across restart and stale-clear races. +3. Status and request handlers both enforce the decision; ownership errors and + queued responses never trigger fresh/resume fallback or manual-override notes. + +## Verification + +Run from the repository root. Bootstrap dependencies once if absent. + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/backend && go test -race ./internal/orchestrator -run 'TestQueuedSessionInspection|TestWorkflowParking|Test.*Resume|Test.*ProfileSwitch' -count=1) +(cd apps/backend && go test ./internal/task/repository/sqlite -run 'TestWorkflowParking' -count=1) +(cd apps/backend && test -n "$KANDEV_TEST_POSTGRES_DSN" && go test ./internal/task/repository/sqlite -run 'TestPostgresWorkflowParking' -count=1) +(cd apps/backend && go test ./internal/task/service -run 'Test.*Turn|Test.*SessionMessage' -count=1) +(cd apps/web && pnpm exec vitest run hooks/domains/session/use-session-resumption.test.ts hooks/domains/session/use-ensure-task-session.test.ts hooks/use-ensure-task-session.test.ts lib/services/session-launch-helpers.test.ts lib/ws/handlers/empty-turn-notice.test.ts) +(cd apps/web && pnpm run typecheck) +make -C apps/backend build +(cd apps/web && pnpm e2e:run --project chromium tests/workflow/queued-session-ownership.spec.ts) +git diff --check +``` + +Add planned missing tests before their commands. PostgreSQL tests use +`testutil.PostgresDSNFromEnv` and isolated schemas. Provision a disposable test +database through the repository's existing test setup and set +`KANDEV_TEST_POSTGRES_DSN` without printing it. The explicit environment guard +prevents a skipped PostgreSQL test from counting as a pass. If unavailable, +record the blocked database check and do not mark this work order done. +Frontend behavior changes here do not change layout, so hook tests plus the +desktop flow suffice for this slice; Task 03 owns the full mobile flow. + +## Files likely touched + +- `apps/backend/internal/task/models/models.go` and a focused parking model file. +- `apps/backend/internal/task/repository/interface.go`, `sqlite/session.go`, + `sqlite/session_workflow.go`, and new `workflow_parking_test.go`. +- `apps/backend/internal/orchestrator/workflow_profile_session_lifecycle.go`. +- `apps/backend/internal/orchestrator/session_launch.go`, `task_operations.go`, + `ceiling_seam4.go`, `ceiling_replay.go`, and new `queued_session_inspection_test.go`. +- `apps/backend/internal/orchestrator/dto/dto.go` (`TaskSessionStatusResponse`), + `session_ensure.go`, and launch/ensure HTTP/WS mappings. +- `apps/web/lib/services/session-launch-service.ts`, `session-launch-helpers.ts`. +- `apps/web/hooks/domains/session/use-session-resumption.ts`, + `use-session-resumption-operations.ts`, and both + `hooks/use-ensure-task-session.ts` and `hooks/domains/session/use-ensure-task-session.ts`. +- Their test files and the workflow E2E file/helper named above. + +## Dependencies + +None. Read the existing profile-switch, resume, launch, and empty-turn tests first. + +## Risks + +Stop-intent presence is not current parking. A queued or suppressed launch +response must not be interpreted as a successful running session. A status read +must not clear markers or promote the selected tab. + +## Parallelism + +`sequential` + +## Inputs + +[Design: inspection intent and durable workflow parking](../../specs/tasks/system-design/queued-session-ownership.md#inspection-intent). +Use `event_handlers_workflow_profile_session_policy_test.go` and existing +`use-session-resumption.test.ts` fixtures. Read the [plan's E2E contract](plan.md#e2e-tests). + +## Results + +Implementation is present. The durable stamped parking marker, passive launch +source, status eligibility guard, and browser recovery propagation are covered +by the existing orchestrator/model tests and the new repository parking test. +The desktop workflow scenario confirms that inspecting the queued task and its +parked source does not start, prompt, or queue activity for the source session. + +Passing checks: + +- `go test -race ./internal/orchestrator -run + 'TestQueuedSessionInspection|TestWorkflowParking|Test.*Resume|Test.*ProfileSwitch' + -count=1` +- `go test ./internal/task/repository/sqlite -run 'TestWorkflowParking' -count=1` +- The focused frontend recovery, ensure, launch-helper, and empty-turn tests + passed (7 files, 97 tests). +- `pnpm run typecheck` +- `make -C apps/backend build` +- `pnpm e2e:run --project chromium + tests/workflow/queued-session-ownership.spec.ts` +- `git diff --check` + +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. diff --git a/docs/plans/queued-session-ownership/task-02-preserve-queued-entry.md b/docs/plans/queued-session-ownership/task-02-preserve-queued-entry.md new file mode 100644 index 0000000000..89da4edbe5 --- /dev/null +++ b/docs/plans/queued-session-ownership/task-02-preserve-queued-entry.md @@ -0,0 +1,136 @@ +--- +id: "02-preserve-queued-entry" +title: "Preserve queued entry lifecycle" +status: in_progress +wave: 2 +depends_on: + - "01-preserve-parked-sessions" +plan: "plan.md" +requirements: + - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-002 +acceptance_criteria: + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.1 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.2 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.3 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.4 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.5 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.6 +system_design: + - ../../specs/tasks/system-design/queued-session-ownership.md +--- + +# Task 02: Preserve queued entry lifecycle + +## Summary + +Make task state and replay respect the exact queued workflow entry. A sibling +ready event must not hide Luna's queue, and a stale replay must not affect a +newer entry. + +## In scope + +- Bind workflow deferrals to the committed route/step/session identity. +- Guard review-state reconciliation with working-session and valid-deferral + predicates, including restoration from legacy REVIEW plus accepted deferral. +- Serialize enqueue/state writes and observed-record dispatch/clear against + successor records. Preserve prompt, turn, and session identity across retries. +- Dispose obsolete/deleted/terminal destinations without retargeting; preserve + records on transient failures. Keep archive/cancel and Office precedence. +- Extend the desktop E2E flow to release capacity and verify Luna's one delivery. + +## Out of scope + +Queue rendering and summary schema belong to Task 03. No new scheduler, generic +retry framework, WIP policy, global ordering, or profile-selection algorithm. + +## Acceptance + +1. `TestQueuedSessionOwnership` fails before the fix when a sibling boot-ready + event turns SCHEDULING into REVIEW. Mixed CREATED+idle/failed/cancelled cases + preserve Scheduling; explicit sibling running and settlement retain queue ownership. +2. `TestQueuedEntryReplay` proves one destination delivery, restart recovery, + old-record compatibility, invalidation on step change/deletion/cancellation, + and transient-error retention without retargeting or duplicate prompts. +3. Barrier-controlled races prove an old REVIEW write or replay clear cannot + overwrite a new record. Stale callbacks cannot activate parked Astra. + +## Verification + +```bash +(cd apps/backend && go test -race ./internal/orchestrator -run 'TestQueuedSessionOwnership|TestQueuedEntryReplay|Test.*Ceiling|Test.*ReviewState|Test.*BootReady' -count=1) +(cd apps/backend && go test ./internal/task/repository/sqlite -run 'Test.*DeferredLaunch|Test.*QueuedEntry' -count=1) +(cd apps/backend && test -n "$KANDEV_TEST_POSTGRES_DSN" && go test ./internal/task/repository/sqlite -run 'TestPostgresQueuedEntry' -count=1) +(cd apps/backend && go test ./internal/task/service -run 'Test.*DeferredLaunch|Test.*Workflow' -count=1) +make -C apps/backend build +(cd apps/web && pnpm e2e:run --project chromium tests/workflow/queued-session-ownership.spec.ts tests/workflow/workflow-session-targeting.spec.ts) +git diff --check +``` + +Use a controllable runtime and real repository fixtures for RED and race cases. +Do not substitute timing sleeps for an ordered conflicting operation. Record +actual database coverage. `TestPostgresQueuedEntry` is a planned isolated-schema +test using `KANDEV_TEST_POSTGRES_DSN` from Task 01. A missing database blocks this +check; a skipped test is not verification. Keep the work order pending until its +required database checks pass. + +## Files likely touched + +- `apps/backend/internal/orchestrator/event_handlers_streaming.go`, + `event_handlers_agent.go`, `event_handlers_workflow.go`. +- `apps/backend/internal/orchestrator/ceiling_defer.go`, `ceiling_replay.go`, + `ceiling_seam2.go`, `ceiling_seam3.go`, and workflow route integration. +- `apps/backend/internal/task/models/ceiling_launch.go` and route model helpers. +- `apps/backend/internal/task/repository/interface.go`, + `sqlite/deferred_launch_cas.go`, `sqlite/task.go`, and corresponding tests. +- New `apps/backend/internal/orchestrator/queued_session_ownership_test.go`. +- Workflow E2E file/helper introduced in Task 01. + +## Dependencies + +Task 01 establishes parked policy and passive launch outcomes. Its inspection +tests remain mandatory compatibility evidence. + +## Risks + +Task-state serialization alone does not protect against an independently written +deferred record. Guard identity at the persistence boundary. Validity checks +must distinguish source read errors from proven obsolete work. + +## Parallelism + +`sequential` + +## Inputs + +[Design: deferred entry ownership and task reconciliation](../../specs/tasks/system-design/queued-session-ownership.md#deferred-entry-ownership). +Read existing `ceiling_replay_test.go`, `ceiling_defer_test.go`, +`event_handlers_workflow_deferred_launch_race_test.go`, and deferred CAS tests. + +## Results + +Implementation is present. Deferred entries retain the exact workflow route, +step, destination session, prompt, and queue time. Review-state reconciliation, +replay clearing, and Send Now admission use the destination identity and +compare-and-set rules. A created destination now takes the explicit manual +Send Now path, and a failed dispatch remains restorable by the queue worker. + +Passing checks: + +- `go test -race ./internal/orchestrator -run + 'TestQueuedSession|TestQueuedEntry|Test.*Ceiling.*Surface|Test.*LaunchQueue' + -count=1` +- `go test ./internal/task/repository/sqlite -run + 'Test.*DeferredLaunch|Test.*QueuedEntry' -count=1` +- `go test ./internal/task/service -run + 'Test.*DeferredLaunch|Test.*Workflow' -count=1` +- `go test ./internal/orchestrator -run + 'TestPromptSendNowClaimStartsCreatedSessionManually|TestPromptSendNowClaimSkipsOnTurnStartWhenAlreadyProcessed' + -count=1` +- `make -C apps/backend build` +- `pnpm e2e:run --project chromium tests/workflow/queued-session-ownership.spec.ts + tests/workflow/workflow-session-targeting.spec.ts` (6 tests passed) +- `git diff --check` + +The required PostgreSQL queued-entry check remains outstanding because +`KANDEV_TEST_POSTGRES_DSN` is unset. The work order remains in progress until +the isolated PostgreSQL verification runs. 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 new file mode 100644 index 0000000000..c97d027027 --- /dev/null +++ b/docs/plans/queued-session-ownership/task-03-expose-queue-status.md @@ -0,0 +1,201 @@ +--- +id: "03-expose-queue-status" +title: "Expose queue status across task surfaces" +status: in_progress +wave: 3 +depends_on: + - "02-preserve-queued-entry" +plan: "plan.md" +requirements: + - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-003 +acceptance_criteria: + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.1 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.2 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.3 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.4 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.5 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.6 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.7 +system_design: + - ../../specs/tasks/system-design/queued-session-ownership.md +--- + +# Task 03: Expose queue status across task surfaces + +## Summary + +Deliver the bounded queue summary and its desktop/phone presentation as one +vertical slice. Users can inspect either conversation while seeing which +session is queued, why it waits, and that retry is automatic. + +## In scope + +- `launch_queue` summary model, validation, source loading, rebuild, live + publication, revision/invalidation, and explicit removal. +- Capacity sampling and freshness without semantic task-activity churn. +- Shared queue view model, sidebar label, task-level status region, selected + session parking note, and suppression of duplicate queued-session launch prompts. +- Full desktop/mobile lifecycle, reload, disconnect, unknown-capacity, + long-name, prompt-warning, question/error coexistence, and dispatch coverage. +- English, Portuguese, Simplified Chinese, and generated Traditional Chinese + copy. Public workflow explanation and applicable source/API documentation. + +## Out of scope + +No global queue page, ETA, ordinal queue position, bypass control, new drawer, +or modification of historical status messages. Do not count launches as queued prompts. + +## Acceptance + +1. Task list, boot, detail, and WS paths share the revisioned queue projection; + stale updates cannot resurrect cleared work. Restart restores queued identity, + missing capacity remains unknown, and refresh does not change activity ordering. +2. UI-01 through UI-03 are realized with localized readable status, retained + errors/questions, and independent task queue versus selected-session state. +3. Desktop and phone tests prove passive inspection leaves Astra untouched and + capacity release starts only Luna once, with queue status clearing automatically. + +## ASCII UI preview + +Excerpt of [the full plan previews](plan.md#ascii-ui-preview), requirement 003: + +```text +UI-01 Desktop +Sidebar: [clock] Investigate issue Queued +Detail: Queued: Luna. Waiting for session capacity + 5 of 5. Checked just now. Queued since 21:15 + Starts automatically when capacity is available. + [Astra] [Luna: Queued] [Plan] + Parked for workflow. Send a message to continue here. + +UI-02 Phone +Task drawer row: [clock] Investigate issue Queued +Task detail: + Queued: Luna + Waiting for session capacity + 5 of 5. Checked just now. Queued since 21:15 + Starts automatically. + [Astra v] + Parked for workflow. Send a message to continue here. + Conversation ... + +UI-03 Shared outcomes + Disconnected -> Queued, reconnecting, last checked time + No count -> Capacity unavailable + Replay error -> Retry pending plus existing error details + Dispatched -> Queue region removed; normal Starting/Running +``` + +Queue region is outside chat scroll, regardless of selected conversation. Use +the existing mobile drawer/picker and dedicated layout, with one chat scroll +owner and safe-area clearance. Names wrap in detail and truncate in navigation. +No required hover disclosure. New action targets meet 44 px only on touch/mobile. +These structural requirements are fixed; spacing and wording are illustrative. + +## Verification + +```bash +(cd apps/backend && go test ./internal/task/statussummary ./internal/task/dto -count=1) +(cd apps/backend && go test ./internal/task/service -run 'Test.*StatusSummary|Test.*LaunchQueue' -count=1) +(cd apps/backend && go test -race ./internal/orchestrator -run 'TestQueuedSession|TestQueuedEntry|Test.*Ceiling.*Surface|Test.*LaunchQueue' -count=1) +(cd apps/web && pnpm exec vitest run components/task/launch-queue-status.test.tsx lib/tasks/launch-queue-view-model.test.ts lib/ws/handlers/tasks-status-summary.test.ts hooks/domains/session/use-session-resumption.test.ts) +(cd apps/web && pnpm run i18n:zh-hant && pnpm run i18n:check && pnpm run i18n:ratchet) +(cd apps/web && pnpm run typecheck) +make -C apps/backend build +(cd apps/web && pnpm e2e:run --project chromium tests/workflow/queued-session-ownership.spec.ts tests/workflow/workflow-session-targeting.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/workflow/mobile-queued-session-ownership.spec.ts) +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +Create the named new component/view-model tests during RED. Run any additional +modified test suite explicitly and record its command. The managed E2E runner +builds fresh artifacts and owns cleanup; do not overlap desktop/mobile runs or +use all-worker overrides. Compare rendered phone geometry and labels with UI-02. + +## Files likely touched + +- `apps/backend/internal/task/statussummary/{model,rebuild,projector,projector_events}.go` + and new queue projection tests. +- `apps/backend/internal/task/service/service_status_summary_rebuild.go`, task + DTO enrichment, boot/list wiring, and the existing summary composition provider. +- `apps/backend/internal/orchestrator/ceiling_defer.go`, `ceiling_replay.go`, + `ceiling_sweep.go`, `ceiling_surface.go`, and tests. +- `apps/web/lib/types/task-status-summary.ts`, `http.ts`, `backend.ts`. +- `apps/web/lib/ws/handlers/task-status-summary.ts`, task hydration/merge paths. +- `apps/web/lib/tasks/launch-queue-view-model.ts` (new) and tests. +- `apps/web/components/task/launch-queue-status.tsx` (new), `task-item.tsx`, + `task-switcher.tsx`, `task-chat-panel.tsx`, desktop task layout placement. +- `apps/web/components/task/mobile/session-mobile-layout.tsx`, + `session-task-switcher-sheet.tsx`, and session-picker presentation if needed. +- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}` catalogs. +- Workflow E2E specs/helper introduced by this package. +- `docs/public/tasks-and-workflows.md`; inspect `agents-and-profiles.md` and + `configuration.md` for related wording without changing startup defaults. + +## Dependencies + +Tasks 01 and 02 provide passive eligibility and authoritative queue lifecycle. + +## Risks + +Summary invalidation must carry clears, and old hydration must not resurrect +queue state. Capacity sampling must not re-sort tasks or expose global session +identities. A mobile status region must not cover the session picker or composer. + +## Parallelism + +`sequential` + +## Inputs + +[Design: queue projection and surfaces](../../specs/tasks/system-design/queued-session-ownership.md#queue-projection). +Use existing queued-prompt summary tests as revision/rebuild examples without +merging the two semantics. Read mobile parity, e2e, and docs-maintainer guidance. + +## Results + +Implementation is present. The revisioned `launch_queue` projection is loaded +from durable task metadata, refreshed with capacity observations, and exposed +through task list, sidebar, detail, desktop chat, and mobile navigation. The +shared view model keeps queue status independent from the selected parked +conversation, and the phone layout has one chat scroll owner. + +Passing checks: + +- `go test ./internal/task/statussummary ./internal/task/dto -count=1` +- `go test ./internal/task/service -run + 'Test.*StatusSummary|Test.*LaunchQueue' -count=1` +- `go test -race ./internal/orchestrator -run + 'TestQueuedSession|TestQueuedEntry|Test.*Ceiling.*Surface|Test.*LaunchQueue' + -count=1` +- Focused frontend tests for the queue component, view model, status-summary + handler, and session recovery passed (93 tests). +- `pnpm run typecheck`, `pnpm run lint`, `pnpm run i18n:zh-hant`, + `pnpm run i18n:check`, and `pnpm run i18n:ratchet` +- `pnpm test` passed (2,140 files, 18,465 tests, 4 skipped). +- `make -C apps/backend build` +- `pnpm e2e:run --project chromium tests/workflow/queued-session-ownership.spec.ts + tests/workflow/workflow-session-targeting.spec.ts` +- `pnpm e2e:run --project mobile-chrome + tests/workflow/mobile-queued-session-ownership.spec.ts` +- `node --test scripts/validate-public-docs.test.mjs`, + `node scripts/validate-public-docs.mjs`, `python3 scripts/list-docs.py + validate`, `python3 scripts/lint-spec-files.py --all`, and + `git diff --check` + +The rendered desktop and phone scenarios match the preview contract: queued +destination and capacity are visible while the parked source is selected, +queue status is present in task navigation and detail, names wrap on phone, +and releasing the fixture capacity dispatches the destination once. The full +E2E sleep lint audit retains unrelated baseline violations; the scoped preview +and sleep ratchet pass. + +The required PostgreSQL projection/rebuild coverage remains outstanding because +`KANDEV_TEST_POSTGRES_DSN` is unset. The work order remains in progress until +the isolated PostgreSQL verification runs. The repository-wide backend test +target also reports unrelated existing environment-sensitive failures; the +task-related backend packages pass. diff --git a/docs/plans/session-concurrency-ceiling/plan.md b/docs/plans/session-concurrency-ceiling/plan.md index 3ca81e96bb..352b194c80 100644 --- a/docs/plans/session-concurrency-ceiling/plan.md +++ b/docs/plans/session-concurrency-ceiling/plan.md @@ -42,6 +42,10 @@ remain separate during repair. ## Risks +Follow-up: [queued session ownership](../queued-session-ownership/plan.md) adds +passive-origin enforcement and task queue visibility. Existing manual override +semantics and the completed work order remain unchanged. + - A repository read failure before a process callback is treated as not owned, so a reservation can wait for the sweep instead of being released by an ambiguous callback. diff --git a/docs/plans/workflow-profile-session-reuse/plan.md b/docs/plans/workflow-profile-session-reuse/plan.md index 861da9f31a..4a19583a0d 100644 --- a/docs/plans/workflow-profile-session-reuse/plan.md +++ b/docs/plans/workflow-profile-session-reuse/plan.md @@ -119,6 +119,12 @@ and save behavior. - [ ] [Task 03: Simplify the selector and add agent logos](task-03-expose-workflow-policy.md) - [ ] [Task 04: Prove all lifecycle combinations](task-04-prove-and-document-reuse-flows.md) +## Follow-up delivery + +[Queued session ownership](../queued-session-ownership/plan.md) owns the later +inspection/ceiling intersection and its regression matrix. This historical +package does not establish that opening a parked predecessor is safe. + ## Risks - A transition path can use the destination end setting by mistake. diff --git a/docs/plans/workflow-same-profile-new-session/plan.md b/docs/plans/workflow-same-profile-new-session/plan.md index 24426c3a9d..65cc20fa22 100644 --- a/docs/plans/workflow-same-profile-new-session/plan.md +++ b/docs/plans/workflow-same-profile-new-session/plan.md @@ -111,6 +111,10 @@ the shared backend policy rather than phone interaction. ## Related delivery records +[Queued session ownership](../queued-session-ownership/plan.md) adds the +parked-predecessor and deferred-destination intersection. This completed repair +retains its original scope and results. + [Workflow session targeting](../workflow-session-targeting/plan.md) delivered explicit initial and source-step recipients. Its completed results remain historical evidence, not proof of this profile-only case. This package owns diff --git a/docs/plans/workflow-session-targeting/plan.md b/docs/plans/workflow-session-targeting/plan.md index 2a88a83355..f6bae27f45 100644 --- a/docs/plans/workflow-session-targeting/plan.md +++ b/docs/plans/workflow-session-targeting/plan.md @@ -13,6 +13,10 @@ legacy_specs: [] ## Overview +Follow-up: [queued session ownership](../queued-session-ownership/plan.md) covers +inspection and admission after recipient selection. Its new cases do not reopen +this package's delivered routing work or reuse its test results as queue evidence. + Allow Review to return to the task's initial agent or an earlier step's conversation. Keep **When this step starts** and **When this step ends** as independent controls. Keep lifecycle navigation visible above the recipient list. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index cb587bd196..1132b7007b 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -247,6 +247,11 @@ exclusive health probe before it becomes eligible again. This shared error classification is used by task/Kanban and Office routing, while the per- candidate policies are configured on dynamic profiles. +When a task launch waits for session capacity, Kandev keeps the selected +destination and retries it automatically. Inspecting another session does not +resume a parked predecessor. Use an explicit **Resume** action or send a +message for manual recovery. These actions can override the automatic ceiling. + Provider errors that occur before a result can use the configured action, such as retrying the current candidate or trying the next candidate. A started turn with an ambiguous result does not switch providers automatically. If no diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index cbeead3015..92ca87ac30 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -601,6 +601,18 @@ destination queue. If the configured feeder is also full, creation returns a conflict. Ephemeral tasks are not counted. +A task can also show **Queued** after workflow entry selects a session but the +agent session ceiling blocks automatic launch. This is different from WIP +queueing: WIP waits before destination entry, while session-capacity queueing +keeps the selected session and retries it automatically. Task details and the +task navigator show the destination, the latest capacity observation, queue +time, and retry state. They do not show a queue position or estimated start +time. Capacity counts older than 40 seconds, or counts unavailable because the +client is disconnected, are labelled stale while the destination remains +visible. Opening a task or a parked predecessor does not start it. Use the +explicit **Start** or **Resume** action, or send a message, to override the +automatic ceiling for that conversation. + Integration watchers use the same admission rule. For example, a GitHub review watch targeting a `Review` step with a limit of two admits at most two newly observed pull requests at a time. Pull requests that lose the capacity race diff --git a/docs/specs/agents/system-design/session-concurrency-ceiling.md b/docs/specs/agents/system-design/session-concurrency-ceiling.md index c99ae79a11..eee6750bd3 100644 --- a/docs/specs/agents/system-design/session-concurrency-ceiling.md +++ b/docs/specs/agents/system-design/session-concurrency-ceiling.md @@ -63,6 +63,11 @@ origins never become manual during replay. The sweep clears a record only after successful dispatch. A repeated refusal leaves the original timestamp and payload in place. +The task-owned [queued session ownership design](../../tasks/system-design/queued-session-ownership.md) +plans explicit passive-inspection classification at the caller boundary. Opening +a conversation is not a manual override. Actual explicit execution retains this +design's manual admission rule; workflow parking eligibility remains task-owned. + ## Failure and recovery Reservation ownership is session-keyed. A stale process-start callback first diff --git a/docs/specs/tasks/requirements/prevent-agent-autostart-on-open.md b/docs/specs/tasks/requirements/prevent-agent-autostart-on-open.md index 1f107dc969..25b4b77d4f 100644 --- a/docs/specs/tasks/requirements/prevent-agent-autostart-on-open.md +++ b/docs/specs/tasks/requirements/prevent-agent-autostart-on-open.md @@ -12,6 +12,14 @@ owners: This document is the migrated task-system source for the capability. The source detail below remains authoritative while the system is migrated into separate requirement and design records. +### Queued session ownership restriction + +[Queued session ownership](queued-session-ownership.md) defines the restriction on +open-time recovery: workflow-parked sessions and an accepted queued destination +remain protected with either preference value. The default-off behavior below +continues for otherwise eligible sessions. Explicit user execution and legitimate +workflow re-entry retain their own activation paths. + ## Requirements ### REQ-TASKS-PREVENT-AGENT-AUTOSTART-ON-OPEN-001: Prevent Agent Auto-Start On Open @@ -221,4 +229,4 @@ per-task variant in this feature. - Whether the option should also gate the office advanced mode's `ensureExecution` resume once that WS field is actually wired. Currently a - no-op; intentionally out of scope until the office flow auto-starts. \ No newline at end of file + no-op; intentionally out of scope until the office flow auto-starts. diff --git a/docs/specs/tasks/requirements/queued-session-ownership.md b/docs/specs/tasks/requirements/queued-session-ownership.md new file mode 100644 index 0000000000..5ae6c8337c --- /dev/null +++ b/docs/specs/tasks/requirements/queued-session-ownership.md @@ -0,0 +1,135 @@ +--- +status: draft +system: tasks +created: 2026-09-16 +owners: + - kandev +--- + +# Queued session ownership requirements + +## 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, +durable launch intent, and its presentation across task surfaces. + +This capability extends [workflow session lifecycle](workflow-profile-session-lifecycle.md). +The [agent ceiling](../../agents/requirements/session-concurrency-ceiling.md) +continues to own capacity admission. A ceiling queue is distinct from a +[workflow WIP queue](wip-limit-pull-system.md): WIP admission occurs before +destination entry, whereas a ceiling can delay a session already selected by entry. + +## Terminology + +- **Passive inspection:** Opening, focusing, reloading, reconnecting, previewing, + or selecting a conversation without requesting execution. +- **Parked predecessor:** A conversation stopped by a workflow session switch. + It remains available for reading, explicit follow-up, or later workflow reuse. +- **Queued destination:** The exact session selected for a deferred workflow launch. +- **Explicit execution:** A user selects Start/Resume or sends a message to an + identified conversation. A valid workflow re-entry is a separate authorized trigger. + +## Requirements + +### REQ-TASKS-QUEUED-SESSION-OWNERSHIP-001: Inspection preserves execution ownership + +**Intent:** Reading a task must not wake its retired workflow participant. + +#### 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.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. +- **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. +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.4:** A later workflow entry that + legitimately selects a parked conversation shall be able to reuse it under + the existing lifecycle policy. Historical parking shall not permanently block reuse. +- **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 + established, passive inspection shall remain available without starting work + or attempting a fresh-session fallback. Explicit recovery shall remain separately available. + +### 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. + +#### Acceptance criteria + +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.1:** While an eligible queued + destination exists and no session is executing work, the task shall remain + Scheduling. A sibling becoming ready, idle, stopped, failed, or completed + shall not move that task to Review or clear its queue. +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.2:** If a sibling explicitly runs + while the destination is queued, task activity shall reflect the running work + and retain the queue indicator. When that sibling settles, the task shall + return to Scheduling while the destination remains queued. +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.3:** When capacity becomes available, + automatic retry shall deliver the accepted launch to its exact eligible + destination once. It shall not wake or send the step prompt to the predecessor. +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.4:** Reload and backend restart shall + preserve queued ownership and parked inspection behavior. Repeated refusal + shall not reset queue time, replace the accepted prompt, or duplicate sessions. +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.5:** If the destination is removed, + cancelled, or superseded by a later workflow entry, its obsolete launch shall + not run or retarget another session. The task shall expose the disposition. + A transient read or replay error shall preserve accepted work for retry. +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.6:** A stale replay or lifecycle event + shall not clear, launch, or reconcile state for a newer queued entry. Explicit + task cancellation and archive shall retain their existing precedence. + +### REQ-TASKS-QUEUED-SESSION-OWNERSHIP-003: Visible launch queue + +**Intent:** Users can distinguish waiting for capacity from a stopped or failed agent. + +#### Acceptance criteria + +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.1:** Desktop sidebar rows and the + phone task navigator shall expose a labelled Queued indicator for deferred + session launches, without requiring the queued conversation to be selected. +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.2:** Task details shall show the + 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.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 + shall remain visible alongside queue status. +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.5:** Capacity observations shall show + when they were checked. When unavailable or disconnected, the interface + shall retain known queued ownership and identify stale or unavailable counts. + It shall not claim a queue position or an estimated start time. +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.6:** Phone users shall inspect the + same status and navigate conversations through the existing task drawer and + session picker. Required information shall not depend on hover. New touch + actions shall have at least 44 px hit targets, with no page horizontal overflow. +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.7:** Passive inspection and ordinary + queue waiting shall not generate an empty-output completion warning. A real + prompt that completes without output shall retain its existing warning behavior. + +## Compatibility and exclusions + +Actual manual starts and resumes retain the existing ceiling override. This +package does not turn the ceiling into a hard limit for explicit execution. +Ordinary open-time recovery remains preference-controlled, subject to the +ownership and automatic-admission conditions above. + +No new session-state enum, global queue dashboard, queue reordering, ETA, +capacity setting, workflow lifecycle setting, or provider-routing policy is added. +Office scheduling, WIP admission order, terminal conversation recovery, and +unrelated prompt queues retain their owning contracts. + +## Design + +- [Queued session ownership](../system-design/queued-session-ownership.md) diff --git a/docs/specs/tasks/requirements/workflow-profile-session-lifecycle.md b/docs/specs/tasks/requirements/workflow-profile-session-lifecycle.md index 5fcb7d4586..380cbc6e55 100644 --- a/docs/specs/tasks/requirements/workflow-profile-session-lifecycle.md +++ b/docs/specs/tasks/requirements/workflow-profile-session-lifecycle.md @@ -34,6 +34,10 @@ fresh conversation for independent work. - **Profile re-entry:** A profile switch to an agent profile that already has a session on the task. +Inspection of parked sessions and deferred destination launches is specified in +[queued session ownership](queued-session-ownership.md). Selecting a conversation +for reading is not a workflow re-entry or an explicit request to execute it. + ## Requirements ### REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001: Configurable step profile-session lifecycle diff --git a/docs/specs/tasks/system-design/queued-session-ownership.md b/docs/specs/tasks/system-design/queued-session-ownership.md new file mode 100644 index 0000000000..096e103f5e --- /dev/null +++ b/docs/specs/tasks/system-design/queued-session-ownership.md @@ -0,0 +1,240 @@ +--- +status: draft +system: tasks +requirements: + - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-001 + - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-002 + - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-003 +--- + +# Queued session ownership system design + +## Purpose and boundaries + +This design extends [workflow session lifecycle](workflow-profile-session-lifecycle.md) +at the boundary between an accepted recipient, launch admission, and inspection. +It uses the existing [agent ceiling](../../agents/system-design/session-concurrency-ceiling.md) +and task-owned deferred record. It does not replace either queue or change WIP admission. + +## Requirement mapping + +| Requirement | Sections | +| --- | --- | +| REQ-TASKS-QUEUED-SESSION-OWNERSHIP-001 | Inspection intent; Durable workflow parking | +| 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 | + +## Existing components + +- `workflow_profile_session_lifecycle.go` parks a source and records an + execution-stamped stop intent. That record has a durable consumed tombstone. +- `session_launch.go:launchResume` classifies origin from `AutoStart`. + `buildResumeRequest` currently omits that field for both explicit and open-time recovery. +- `GetTaskSessionStatus` reports runtime resumability. The browser's + `use-session-resumption.ts` drives recovery after a status read. +- `handleAgentBootReady` calls `setSessionWaitingForInput`, which calls + `writeTaskReviewState`. The current sibling guard recognizes working sessions, + but not an eligible CREATED destination whose launch is deferred. +- `ceiling_replay.go` replays by stored launch kind every 20 seconds and on + release. `ceiling_surface.go` writes historical status messages. +- `task/statussummary` persists a revisioned, bounded summary used by task + list and detail consumers. Extend it rather than inventing a frontend queue. + +## Inspection intent + +Add optional `activation_source: "user_action" | "session_open"` to the unified +launch request. Omission preserves existing explicit-call behavior. Reject +unknown values. `session_open` cannot carry a prompt or grant recovery permission. +The backend forces automatic admission for this source even if `auto_start` +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 +`is_resumable` for explicit recovery. Populate this through one orchestrator +eligibility helper shared by status, launch, and open-time ensure paths. + +The browser consults these fields before `markSessionStarting`. Every passive +resume sends `session_open`; explicit Resume and Send remain user actions. +Inspect `useEnsureTaskSession` and `session.ensure` as well as tab recovery. +An ensure for a task with an accepted queued destination returns that destination +without launching, allocating another session, or switching to the visible tab. + +Recheck under the existing session/entry lifecycle guards before reservation, +turn creation, runtime launch, prompt creation, or fresh fallback. Return a +successful no-execution disposition, proposed `activation_disposition: +"suppressed" | "queued"`, with the reason and current session. Teach +`session-launch-service.ts` and recovery operations to accept this as waiting, +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 +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. + +## Durable workflow parking + +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. + +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. + +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 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. + +## Deferred entry ownership + +Extend workflow-origin deferred payloads with a nested entry binding containing +workflow ID, destination step ID, committed route operation ID, and destination +session ID. Obtain these from the existing `workflow_session_route` contract, +not asynchronous history. Preserve the first payload and enqueue time across refusals. +Generic start/resume records outside workflow entry keep their own eligibility rules. + +Before replay, validate task membership, current entry identity, recipient, +session state, and archive/cancellation status. A superseded, deleted, or terminal +destination receives a final disposition without retargeting. Read failures +retain the record. Old records without the binding are replayable only when the +current committed route and exact session unambiguously agree; enrich them by CAS. +Ambiguous records remain visible as requiring recovery, without dispatch. + +Carry the observed record identity through dispatch and clear. A successful +dispatch may clear only that record; it must not re-read and strip a successor's +ceiling keys. Route changes, archive/cancel, and replay claims serialize using +existing task-entry and lifecycle ownership. Never hold a DB transaction while +calling the runtime. Add conditional repository operations where the existing +read-then-write sequence cannot prove atomic ownership. + +Recheck the claimed route before prompt admission. Preserve the stored turn and +prompt ownership on retry; a second sweep must not create a second step message. +An already-dispatched exact execution is acknowledged through existing correlated +launch identity. No new scheduler, fan-out queue, or launch lease system is added. + +## Task reconciliation + +Within `writeTaskReviewState`, use the same task-runtime serialization as running +state reconciliation. Reconcile eligible non-Office, non-archived tasks as follows: + +1. Any authorized working session preserves normal IN_PROGRESS behavior. +2. Otherwise, any valid deferred destination preserves or restores SCHEDULING. +3. Only with neither condition may ordinary completion reconcile to REVIEW. + +The second predicate is existential: a CREATED queued destination protects the +task even when a sibling is idle, failed, cancelled, or completed. An arbitrary +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 +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. + +The sweep also repairs legacy REVIEW+valid-deferral tasks to SCHEDULING when no +session is working. This repair requires authoritative task and entry evidence; +the browser never writes task state based on a badge. + +## Queue projection + +Add optional `launch_queue` to `TaskStatusSummary` and its frontend type. It is +a complete replacement value under the existing summary revision: + +```text +launch_queue: null | { + session_id?, agent_profile_id?, workflow_step_id?, queued_at, + reason: "session_capacity" | "ownership_unavailable" | "replay_error", + retrying: boolean, + capacity: null | { in_use, limit, observed_at } +} +``` + +For session-backed workflow launches, session and profile are required. A +sessionless start can use the generic task queue label without fabricating a +session ID; it does not participate in parked-predecessor recipient matching. + +Project sanitized identity and status from the durable ceiling record through a +narrow provider at the task-service/orchestrator composition boundary. The task +summary must not import the orchestrator or expose raw replay payloads, prompts, +environment variables, or credentials. Resolve profile names through the existing +authorized profile catalog; fall back to a generic session label if unavailable. + +Extend summary rebuild, equality, validation, live projection, boot/list/detail +enrichment, and `task.status_summary.updated`. Include explicit queue removal in +the replacement summary. Missing fields on legacy snapshots are not a newer clear. +Reuse existing revision/invalidation handling in `task-status-summary.ts` and +task hydration; test old list snapshots after a queued update and after a clear. + +Admission/refusal/dispatch/drop changes invalidate and rebuild affected summaries. +The existing sweep refreshes observations at most once per pass; use one shared +population sample per pass rather than a count query per row. Capacity is sampled +from the ceiling controller, not copied forever from the original refusal. +Changes to observations do not advance semantic task activity or reorder recent +tasks. On restart rebuild queue ownership first; unavailable capacity is null. +Observations older than two sweep intervals (40 seconds), or a disconnected +client, are labelled stale. UI cannot infer dispatch from a count below the limit. + +Queue presence does not increase `queued_prompt_count`: a deferred launch and a +user prompt queue are different contracts. Pending question/error precedence and +existing background-work indicators remain intact. No ordinal position is shown. + +## Desktop and mobile surfaces + +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 +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. + +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, +and safe-area handling. Existing explicit execution controls remain available; +no new bypass button is needed. Status text is keyboard/screen-reader readable, +uses restrained polite announcements for state changes, and never announces every +capacity sample. All new copy uses five-language i18n and the Traditional Chinese +generation command. Previews and viewport assertions live in the work orders. + +## Failure and observability + +Emit structured, bounded reason codes for suppressed inspection, 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. + +Keep historical ceiling notes as history. The live status is independent and +does not rewrite old audit messages. An ordinary deferral does not mean an empty +prompt completed. Verify empty-turn handling at the real event boundary; the +reported warning's exact origin was not proven, so do not remove valid warnings +or claim its cause without a failing regression. + +## Related records + +- [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 238b6c9c82..1f03bc7919 100644 --- a/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md +++ b/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md @@ -487,6 +487,10 @@ 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 + [inspection and queue design](queued-session-ownership.md). Stop-intent + tombstones remain callback evidence, not current activation policy. - [Explicit session targeting](../../../plans/workflow-session-targeting/plan.md) - [Same-profile fresh-session repair](../../../plans/workflow-same-profile-new-session/plan.md)