fix(backend): stop a duplicate lifecycle turn from hiding a pending clarification (#2989)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -554,6 +554,34 @@ func ensureSeededTurn(ctx context.Context, repo *sqliterepo.Repository, sessionI
|
||||
return turn.ID, nil
|
||||
}
|
||||
|
||||
// createSeededTurn always creates a fresh turn rather than reusing the
|
||||
// session's active one, so a spec can hold a real open turn on the session
|
||||
// while seeding a second, independently-timed turn alongside it (see
|
||||
// seedMessageRequest.NewTurn). startedAt/completedAt default to "now" and
|
||||
// "open" respectively when nil.
|
||||
func createSeededTurn(ctx context.Context, repo *sqliterepo.Repository, sessionID, taskID string, startedAt, completedAt *time.Time) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
turn := &models.Turn{
|
||||
ID: uuid.New().String(),
|
||||
TaskSessionID: sessionID,
|
||||
TaskID: taskID,
|
||||
StartedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if startedAt != nil {
|
||||
turn.StartedAt = startedAt.UTC()
|
||||
}
|
||||
if completedAt != nil {
|
||||
ca := completedAt.UTC()
|
||||
turn.CompletedAt = &ca
|
||||
}
|
||||
if err := repo.CreateTurn(ctx, turn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return turn.ID, nil
|
||||
}
|
||||
|
||||
func publishSessionStateChanged(ctx context.Context, eventBus bus.EventBus, session *models.TaskSession, log *logger.Logger) {
|
||||
if eventBus == nil {
|
||||
return
|
||||
@@ -592,6 +620,18 @@ type seedMessageRequest struct {
|
||||
// message is attached to), so e2e specs can exercise the message metadata
|
||||
// dialog's turn_metadata field end to end.
|
||||
TurnMetadata map[string]interface{} `json:"turn_metadata,omitempty"`
|
||||
// NewTurn, when true, creates a brand-new turn for this message instead
|
||||
// of reusing the session's active turn. Lets a spec construct two turns
|
||||
// on the same session (e.g. a still-open turn plus a separate,
|
||||
// later-started lifecycle-shaped turn) to exercise current-turn-authority
|
||||
// ordering (D1) end to end, which ensureSeededTurn's reuse-active-turn
|
||||
// behavior cannot produce.
|
||||
NewTurn bool `json:"new_turn,omitempty"`
|
||||
// TurnStartedAt/TurnCompletedAt set the new turn's timestamps when
|
||||
// NewTurn is true, so a spec can control D1's ordering directly. Ignored
|
||||
// when NewTurn is false.
|
||||
TurnStartedAt *time.Time `json:"turn_started_at,omitempty"`
|
||||
TurnCompletedAt *time.Time `json:"turn_completed_at,omitempty"`
|
||||
}
|
||||
|
||||
// seedMessageHandler inserts a synthetic message, ensuring an active turn
|
||||
@@ -621,7 +661,12 @@ func seedMessageHandler(repo *sqliterepo.Repository, eventBus bus.EventBus, log
|
||||
return
|
||||
}
|
||||
|
||||
turnID, err := ensureSeededTurn(ctx, repo, session.ID, session.TaskID)
|
||||
var turnID string
|
||||
if req.NewTurn {
|
||||
turnID, err = createSeededTurn(ctx, repo, session.ID, session.TaskID, req.TurnStartedAt, req.TurnCompletedAt)
|
||||
} else {
|
||||
turnID, err = ensureSeededTurn(ctx, repo, session.ID, session.TaskID)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("test harness: ensure turn failed", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
)
|
||||
|
||||
func TestSessionHasPendingClarification(t *testing.T) {
|
||||
@@ -104,6 +106,62 @@ func TestSessionHasLiveClarificationIgnoresDetachedBundle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionHasPendingClarificationSurvivesLifecycleShadowViaRealWritePath
|
||||
// covers the guard-level consumer named in the spec's Verification Notes as
|
||||
// "the most severe consumer": sessionHasPendingClarification must still see
|
||||
// the pending clarification on a real open turn after a synthetic
|
||||
// lifecycle-only turn is written through the actual task-service write path
|
||||
// (Service.CreateMessage with CompletedTurn: true), the same call
|
||||
// Service.createCompletedTurn makes on agent resume. Both turns share this
|
||||
// service's own repo so the guard reads what task/service actually wrote,
|
||||
// not a hand-seeded row shaped to match.
|
||||
func TestSessionHasPendingClarificationSurvivesLifecycleShadowViaRealWritePath(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "task-lifecycle-guard", "session-lifecycle-guard", "step1")
|
||||
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
|
||||
|
||||
taskSvc := taskservice.NewService(taskservice.Repos{
|
||||
Workspaces: repo,
|
||||
Tasks: repo,
|
||||
TaskRepos: repo,
|
||||
Workflows: repo,
|
||||
Messages: repo,
|
||||
Turns: repo,
|
||||
Sessions: repo,
|
||||
GitSnapshots: repo,
|
||||
RepoEntities: repo,
|
||||
Executors: repo,
|
||||
Environments: repo,
|
||||
TaskEnvironments: repo,
|
||||
Reviews: repo,
|
||||
}, bus.NewMemoryEventBus(testLogger()), testLogger(), taskservice.RepositoryDiscoveryConfig{})
|
||||
|
||||
now := time.Now().UTC()
|
||||
requireNoError(t, repo.CreateTurn(ctx, &models.Turn{
|
||||
ID: "turn-open-guard", TaskSessionID: "session-lifecycle-guard", TaskID: "task-lifecycle-guard",
|
||||
StartedAt: now, CreatedAt: now,
|
||||
}))
|
||||
requireNoError(t, repo.CreateMessage(ctx, &models.Message{
|
||||
ID: "clarification-guard", TaskSessionID: "session-lifecycle-guard", TaskID: "task-lifecycle-guard",
|
||||
TurnID: "turn-open-guard", AuthorType: models.MessageAuthorAgent,
|
||||
Type: models.MessageTypeClarificationRequest, Content: "q", CreatedAt: now,
|
||||
Metadata: map[string]any{"pending_id": "pending-guard", "status": "pending"},
|
||||
}))
|
||||
|
||||
if _, err := taskSvc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
|
||||
TaskSessionID: "session-lifecycle-guard",
|
||||
Content: "resumed",
|
||||
CompletedTurn: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create lifecycle turn via CreateMessage: %v", err)
|
||||
}
|
||||
|
||||
if !svc.sessionHasPendingClarification(ctx, "session-lifecycle-guard") {
|
||||
t.Fatal("guard cleared after a lifecycle-only turn shadowed the open turn's pending clarification")
|
||||
}
|
||||
}
|
||||
|
||||
func seedPendingClarificationMessage(t *testing.T, repo interface {
|
||||
CreateTurn(ctx context.Context, turn *models.Turn) error
|
||||
CreateMessage(ctx context.Context, message *models.Message) error
|
||||
|
||||
@@ -280,3 +280,41 @@ func TestFromTurnPreservesSubsecondOrderingPrecision(t *testing.T) {
|
||||
t.Fatalf("time.Parse(RFC3339, StartedAt): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFromTurnPreservesLifecycleOnlyMetadata is AC-11's DTO half: the
|
||||
// GET .../turns handler (internal/task/handlers/task_http_handlers.go) does
|
||||
// nothing but call FromTurn per row and gin.Context.JSON the result, so
|
||||
// pinning that FromTurn copies metadata verbatim - and that json.Marshal
|
||||
// keeps the lifecycle_only key - proves the response payload carries it,
|
||||
// without standing up an HTTP server. turnHistoryPredicate (proven unchanged
|
||||
// by TestTurnReadsHideEmptyUnpublishedReservationUntilMessageEvidence and
|
||||
// friends in the sqlite package) is what keeps this turn in the result set
|
||||
// FromTurn is ever handed.
|
||||
func TestFromTurnPreservesLifecycleOnlyMetadata(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 24, 9, 0, 0, 0, time.UTC)
|
||||
got := FromTurn(&models.Turn{
|
||||
ID: "turn-lifecycle",
|
||||
StartedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Metadata: map[string]interface{}{models.TurnMetaKeyLifecycleOnly: true},
|
||||
})
|
||||
|
||||
if lifecycleOnly, ok := got.Metadata[models.TurnMetaKeyLifecycleOnly]; !ok || lifecycleOnly != true {
|
||||
t.Fatalf("TurnDTO.Metadata[%q] = %v, want true", models.TurnMetaKeyLifecycleOnly, lifecycleOnly)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(TurnDTO): %v", err)
|
||||
}
|
||||
var decoded struct {
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("json.Unmarshal(TurnDTO JSON): %v", err)
|
||||
}
|
||||
if lifecycleOnly, ok := decoded.Metadata[models.TurnMetaKeyLifecycleOnly]; !ok || lifecycleOnly != true {
|
||||
t.Fatalf("decoded metadata.lifecycle_only = %v, want true (raw JSON: %s)", lifecycleOnly, raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +290,12 @@ const TurnMetaKeyRuntimeConfigSnapshot = "runtime_config_snapshot"
|
||||
// was in when the turn started. Absent when the task held no step.
|
||||
const TurnMetaKeyWorkflowStepIDAtStart = "workflow_step_id_at_start"
|
||||
|
||||
// TurnMetaKeyLifecycleOnly marks a turn created only to parent a lifecycle
|
||||
// message (for example the agent_boot script_execution message on resume).
|
||||
// A lifecycle turn never reflects real agent work and must never be current-
|
||||
// turn authority, so every current-turn resolution site excludes it.
|
||||
const TurnMetaKeyLifecycleOnly = "lifecycle_only"
|
||||
|
||||
// TurnMetaKeyPromptDispatchPending marks a successor created before agentctl
|
||||
// acknowledges its prompt. Empty marked turns are not current-turn authority
|
||||
// unless dispatch ambiguity was recorded; publication clears the marker, while
|
||||
|
||||
@@ -116,14 +116,15 @@ func clarificationBundleQuery(drv, whereExtra string) string {
|
||||
)
|
||||
notParentQuestion := dialect.ExcludeTruthyMetadataPredicate(drv, "m.metadata", "parent_question")
|
||||
nonTerminalSession := nonTerminalSessionPredicate("m")
|
||||
predicate, orderBy := currentTurnAuthority(drv, "turn_row")
|
||||
currentTurn := fmt.Sprintf(`m.turn_id = (
|
||||
SELECT turn_row.id
|
||||
FROM task_session_turns turn_row
|
||||
WHERE turn_row.task_session_id = m.task_session_id
|
||||
AND %s
|
||||
ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC
|
||||
ORDER BY %s
|
||||
LIMIT 1
|
||||
)`, turnAuthorityPredicate(drv, "turn_row"))
|
||||
)`, predicate, orderBy)
|
||||
return fmt.Sprintf(`
|
||||
SELECT b.pending_id, b.session_id, b.task_id, b.created_at
|
||||
FROM (
|
||||
|
||||
@@ -32,7 +32,8 @@ func seedBundleTurn(t *testing.T, repo *Repository, turnID, sessionID, taskID st
|
||||
|
||||
// seedBundleTurnAt creates a turn with an explicit started_at/created_at, so
|
||||
// a test can control which of two turns on the same session is newest per
|
||||
// turnAuthorityPredicate's ORDER BY (started_at DESC, created_at DESC, id DESC).
|
||||
// currentTurnAuthority's ordering: open turns (completed_at IS NULL) before
|
||||
// completed ones, then started_at DESC, created_at DESC, id DESC.
|
||||
func seedBundleTurnAt(t *testing.T, repo *Repository, turnID, sessionID, taskID string, at time.Time) {
|
||||
t.Helper()
|
||||
_, err := repo.db.Exec(repo.db.Rebind(`
|
||||
|
||||
@@ -582,13 +582,14 @@ func (r *Repository) FindMessagesByPendingID(ctx context.Context, pendingID stri
|
||||
// cleanup; they never become current input authority.
|
||||
func (r *Repository) FindActiveClarificationMessagesBySessionID(ctx context.Context, sessionID string) ([]*models.Message, error) {
|
||||
drv := r.ro.DriverName()
|
||||
predicate, orderBy := currentTurnAuthority(drv, "turn_row")
|
||||
query := fmt.Sprintf(`
|
||||
WITH current_turn AS (
|
||||
SELECT turn_row.id
|
||||
FROM task_session_turns turn_row
|
||||
WHERE turn_row.task_session_id = ?
|
||||
AND %s
|
||||
ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC
|
||||
ORDER BY %s
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT m.id, m.task_session_id, m.task_id, m.turn_id, m.author_type, m.author_id,
|
||||
@@ -599,7 +600,7 @@ func (r *Repository) FindActiveClarificationMessagesBySessionID(ctx context.Cont
|
||||
AND m.type = 'clarification_request'
|
||||
AND COALESCE(%s, '') IN ('', 'pending')
|
||||
ORDER BY m.created_at ASC, m.id ASC
|
||||
`, turnAuthorityPredicate(drv, "turn_row"), dialect.JSONExtract(drv, "m.metadata", "status"))
|
||||
`, predicate, orderBy, dialect.JSONExtract(drv, "m.metadata", "status"))
|
||||
// First sessionID selects current-turn authority; second scopes messages so
|
||||
// a malformed cross-session turn reference cannot leak another session's row.
|
||||
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), sessionID, sessionID)
|
||||
@@ -661,6 +662,7 @@ func pendingActionsBySessionQuery(driverName string, placeholders []string) stri
|
||||
// Terminal sessions quarantine pending history even when best-effort expiry
|
||||
// persistence failed. Both message CTEs inherit the requested-session
|
||||
// boundary through their task_session_id and turn_id joins to current_turn.
|
||||
predicate, orderBy := currentTurnAuthority(driverName, "turn_row")
|
||||
return fmt.Sprintf(`
|
||||
WITH current_turn AS (
|
||||
SELECT task_session_id, id AS turn_id
|
||||
@@ -669,7 +671,7 @@ func pendingActionsBySessionQuery(driverName string, placeholders []string) stri
|
||||
id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY task_session_id
|
||||
ORDER BY started_at DESC, created_at DESC, id DESC
|
||||
ORDER BY %s
|
||||
) AS rn
|
||||
FROM task_session_turns turn_row
|
||||
WHERE turn_row.task_session_id IN (%s)
|
||||
@@ -706,7 +708,7 @@ func pendingActionsBySessionQuery(driverName string, placeholders []string) stri
|
||||
SELECT task_session_id, 'permission' AS action
|
||||
FROM latest_permissions
|
||||
WHERE rn = 1 AND status IN ('', 'pending')
|
||||
`, placeholderList, turnAuthorityPredicate(driverName, "turn_row"),
|
||||
`, orderBy, placeholderList, predicate,
|
||||
nonTerminalSessionPredicate("turn_row"), statusExpr, statusExpr, permissionOrderExpr)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ func (r *Repository) DetachActiveClarificationMessagesBySessionID(
|
||||
return nil, err
|
||||
}
|
||||
updatedAt := r.nowUTC()
|
||||
predicate, orderBy := currentTurnAuthority(drv, "turn_row")
|
||||
query := fmt.Sprintf(`
|
||||
UPDATE task_session_messages
|
||||
SET metadata = %s, updated_at = ?
|
||||
@@ -66,7 +67,7 @@ func (r *Repository) DetachActiveClarificationMessagesBySessionID(
|
||||
FROM task_session_turns turn_row
|
||||
WHERE turn_row.task_session_id = task_session_messages.task_session_id
|
||||
AND %s
|
||||
ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC
|
||||
ORDER BY %s
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id, task_session_id, task_id, turn_id, author_type, author_id,
|
||||
@@ -74,7 +75,7 @@ func (r *Repository) DetachActiveClarificationMessagesBySessionID(
|
||||
`, clarificationDetachedMetadataExpr(drv),
|
||||
dialect.JSONExtract(drv, "task_session_messages.metadata", "status"),
|
||||
clarificationNotDetachedPredicate(drv),
|
||||
turnAuthorityPredicate(drv, "turn_row"))
|
||||
predicate, orderBy)
|
||||
rows, err := tx.QueryxContext(ctx, r.db.Rebind(query), updatedAt, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("detach active clarification messages: %w", err)
|
||||
@@ -111,6 +112,7 @@ func (r *Repository) ExpireActiveClarificationBundle(
|
||||
return nil, err
|
||||
}
|
||||
updatedAt := r.nowUTC()
|
||||
predicate, orderBy := currentTurnAuthority(drv, "turn_row")
|
||||
query := fmt.Sprintf(`
|
||||
UPDATE task_session_messages
|
||||
SET metadata = %s, updated_at = ?
|
||||
@@ -123,7 +125,7 @@ func (r *Repository) ExpireActiveClarificationBundle(
|
||||
FROM task_session_turns turn_row
|
||||
WHERE turn_row.task_session_id = task_session_messages.task_session_id
|
||||
AND %s
|
||||
ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC
|
||||
ORDER BY %s
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id, task_session_id, task_id, turn_id, author_type, author_id,
|
||||
@@ -131,7 +133,7 @@ func (r *Repository) ExpireActiveClarificationBundle(
|
||||
`, clarificationExpiredMetadataExpr(drv),
|
||||
dialect.JSONExtract(drv, "task_session_messages.metadata", "status"),
|
||||
dialect.JSONExtract(drv, "task_session_messages.metadata", "pending_id"),
|
||||
turnAuthorityPredicate(drv, "turn_row"))
|
||||
predicate, orderBy)
|
||||
rows, err := tx.QueryxContext(ctx, r.db.Rebind(query), updatedAt, sessionID, pendingID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expire active clarification messages: %w", err)
|
||||
@@ -345,6 +347,7 @@ func (r *Repository) loadRestorableClarificationBundle(
|
||||
) ([]*models.Message, error) {
|
||||
pendingIDExpr := dialect.JSONExtract(drv, "m.metadata", "pending_id")
|
||||
bundlePendingIDExpr := dialect.JSONExtract(drv, "bundle.metadata", "pending_id")
|
||||
predicate, orderBy := currentTurnAuthority(drv, "turn_row")
|
||||
// A pending ID spanning message types, sessions, or turns is malformed. The
|
||||
// NOT EXISTS guard intentionally makes the whole bundle ineligible to restore.
|
||||
query := fmt.Sprintf(`
|
||||
@@ -360,7 +363,7 @@ func (r *Repository) loadRestorableClarificationBundle(
|
||||
FROM task_session_turns turn_row
|
||||
WHERE turn_row.task_session_id = m.task_session_id
|
||||
AND %s
|
||||
ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC
|
||||
ORDER BY %s
|
||||
LIMIT 1
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
@@ -377,7 +380,7 @@ func (r *Repository) loadRestorableClarificationBundle(
|
||||
`, pendingIDExpr,
|
||||
nonTerminalSessionPredicate("m"),
|
||||
clarificationResponseDeliveryPendingPredicate(drv, "m"),
|
||||
turnAuthorityPredicate(drv, "turn_row"),
|
||||
predicate, orderBy,
|
||||
bundlePendingIDExpr,
|
||||
)
|
||||
rows, err := tx.QueryxContext(
|
||||
@@ -406,6 +409,7 @@ func (r *Repository) restoreClarificationMessages(
|
||||
) error {
|
||||
updatedAt := r.nowUTC()
|
||||
statusExpr := dialect.JSONExtract(drv, "metadata", "status")
|
||||
predicate, orderBy := currentTurnAuthority(drv, "turn_row")
|
||||
updateQuery := r.db.Rebind(fmt.Sprintf(`
|
||||
UPDATE task_session_messages
|
||||
SET metadata = ?, updated_at = ?
|
||||
@@ -417,14 +421,14 @@ func (r *Repository) restoreClarificationMessages(
|
||||
FROM task_session_turns turn_row
|
||||
WHERE turn_row.task_session_id = task_session_messages.task_session_id
|
||||
AND %s
|
||||
ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC
|
||||
ORDER BY %s
|
||||
LIMIT 1
|
||||
)
|
||||
`,
|
||||
statusExpr,
|
||||
clarificationResponseDeliveryPendingPredicate(drv, "task_session_messages"),
|
||||
nonTerminalSessionPredicate("task_session_messages"),
|
||||
turnAuthorityPredicate(drv, "turn_row"),
|
||||
predicate, orderBy,
|
||||
))
|
||||
restoredMetadataByMessage := make([]map[string]interface{}, len(messages))
|
||||
for i, message := range messages {
|
||||
@@ -479,6 +483,7 @@ func (r *Repository) claimActiveClarificationBundle(
|
||||
// it is replaced with the requested terminal status before this transaction
|
||||
// commits or is rolled back with the transaction.
|
||||
claimAt := r.nowUTC()
|
||||
predicate, orderBy := currentTurnAuthority(drv, "turn_row")
|
||||
claimQuery := fmt.Sprintf(`
|
||||
UPDATE task_session_messages
|
||||
SET metadata = %s, updated_at = ?
|
||||
@@ -491,7 +496,7 @@ func (r *Repository) claimActiveClarificationBundle(
|
||||
FROM task_session_turns turn_row
|
||||
WHERE turn_row.task_session_id = task_session_messages.task_session_id
|
||||
AND %s
|
||||
ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC
|
||||
ORDER BY %s
|
||||
LIMIT 1
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
@@ -506,7 +511,7 @@ func (r *Repository) claimActiveClarificationBundle(
|
||||
)
|
||||
`, dialect.JSONSet(drv, "metadata", "status", clarificationStatusResponding), pendingIDExpr, statusExpr,
|
||||
nonTerminalSessionPredicate("task_session_messages"),
|
||||
turnAuthorityPredicate(drv, "turn_row"), bundlePendingIDExpr)
|
||||
predicate, orderBy, bundlePendingIDExpr)
|
||||
// pendingID is bound once for the outer bundle and once for the malformed-
|
||||
// bundle NOT EXISTS guard.
|
||||
result, err := tx.ExecContext(ctx, r.db.Rebind(claimQuery), claimAt, pendingID, pendingID)
|
||||
@@ -526,6 +531,7 @@ func (r *Repository) loadClaimedClarificationBundle(
|
||||
drv, pendingID string,
|
||||
) ([]*models.Message, error) {
|
||||
claimedStatusExpr := dialect.JSONExtract(drv, "m.metadata", "status")
|
||||
predicate, orderBy := currentTurnAuthority(drv, "turn_row")
|
||||
rows, err := tx.QueryxContext(ctx, r.db.Rebind(fmt.Sprintf(`
|
||||
SELECT m.id, m.task_session_id, m.task_id, m.turn_id, m.author_type, m.author_id,
|
||||
m.content, m.requests_input, m.type, m.metadata, m.created_at, m.updated_at
|
||||
@@ -536,12 +542,12 @@ func (r *Repository) loadClaimedClarificationBundle(
|
||||
FROM task_session_turns turn_row
|
||||
WHERE turn_row.task_session_id = m.task_session_id
|
||||
AND %s
|
||||
ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC
|
||||
ORDER BY %s
|
||||
LIMIT 1
|
||||
)
|
||||
ORDER BY m.created_at ASC, m.id ASC
|
||||
`, dialect.JSONExtract(drv, "m.metadata", "pending_id"), claimedStatusExpr,
|
||||
turnAuthorityPredicate(drv, "turn_row"))), pendingID, clarificationStatusResponding)
|
||||
predicate, orderBy)), pendingID, clarificationStatusResponding)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load claimed clarification bundle: %w", err)
|
||||
}
|
||||
|
||||
@@ -216,17 +216,21 @@ func (r *Repository) GetTurn(ctx context.Context, id string) (*models.Turn, erro
|
||||
return scanTurnRow(row)
|
||||
}
|
||||
|
||||
// GetActiveTurnBySessionID gets the currently active (non-completed) turn for a session
|
||||
// GetActiveTurnBySessionID gets the currently active (non-completed) turn for
|
||||
// a session. This is not the same query as "current turn": it keeps its own
|
||||
// completed_at IS NULL filter alongside currentTurnAuthority's predicate and
|
||||
// ordering (D10) so AbandonOpenTurns' re-bury loop keeps working.
|
||||
func (r *Repository) GetActiveTurnBySessionID(ctx context.Context, sessionID string) (*models.Turn, error) {
|
||||
predicate, orderBy := currentTurnAuthority(r.ro.DriverName(), "turn_row")
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, task_session_id, task_id, execution_profile_id, route_generation, started_at, completed_at, metadata, created_at, updated_at
|
||||
FROM task_session_turns turn_row
|
||||
WHERE turn_row.task_session_id = ?
|
||||
AND turn_row.completed_at IS NULL
|
||||
AND %s
|
||||
ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC
|
||||
ORDER BY %s
|
||||
LIMIT 1
|
||||
`, turnAuthorityPredicate(r.ro.DriverName(), "turn_row"))
|
||||
`, predicate, orderBy)
|
||||
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(query), sessionID)
|
||||
return scanTurnRow(row)
|
||||
}
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"name": "lifecycle_only boolean true excludes the newer turn",
|
||||
"turns": [
|
||||
{ "id": "bool-true-real", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": null, "metadata": {} },
|
||||
{ "id": "bool-true-lifecycle", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T11:00:00Z", "completed_at": null, "metadata": { "lifecycle_only": true } }
|
||||
],
|
||||
"expected_current_turn_id": "bool-true-real"
|
||||
},
|
||||
{
|
||||
"name": "lifecycle_only number 1 excludes the newer turn",
|
||||
"turns": [
|
||||
{ "id": "num-one-real", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": null, "metadata": {} },
|
||||
{ "id": "num-one-lifecycle", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T11:00:00Z", "completed_at": null, "metadata": { "lifecycle_only": 1 } }
|
||||
],
|
||||
"expected_current_turn_id": "num-one-real"
|
||||
},
|
||||
{
|
||||
"name": "lifecycle_only string true excludes the newer turn",
|
||||
"turns": [
|
||||
{ "id": "str-true-real", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": null, "metadata": {} },
|
||||
{ "id": "str-true-lifecycle", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T11:00:00Z", "completed_at": null, "metadata": { "lifecycle_only": "true" } }
|
||||
],
|
||||
"expected_current_turn_id": "str-true-real"
|
||||
},
|
||||
{
|
||||
"name": "lifecycle_only string 1 excludes the newer turn",
|
||||
"turns": [
|
||||
{ "id": "str-one-real", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": null, "metadata": {} },
|
||||
{ "id": "str-one-lifecycle", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T11:00:00Z", "completed_at": null, "metadata": { "lifecycle_only": "1" } }
|
||||
],
|
||||
"expected_current_turn_id": "str-one-real"
|
||||
},
|
||||
{
|
||||
"name": "lifecycle_only null keeps the newer turn eligible",
|
||||
"turns": [
|
||||
{ "id": "null-older", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": null, "metadata": {} },
|
||||
{ "id": "null-newer", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T11:00:00Z", "completed_at": null, "metadata": { "lifecycle_only": null } }
|
||||
],
|
||||
"expected_current_turn_id": "null-newer"
|
||||
},
|
||||
{
|
||||
"name": "lifecycle_only absent key keeps the newer turn eligible",
|
||||
"turns": [
|
||||
{ "id": "absent-older", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": null, "metadata": {} },
|
||||
{ "id": "absent-newer", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T11:00:00Z", "completed_at": null, "metadata": {} }
|
||||
],
|
||||
"expected_current_turn_id": "absent-newer"
|
||||
},
|
||||
{
|
||||
"name": "lifecycle_only boolean false keeps the newer turn eligible",
|
||||
"turns": [
|
||||
{ "id": "bool-false-older", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": null, "metadata": {} },
|
||||
{ "id": "bool-false-newer", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T11:00:00Z", "completed_at": null, "metadata": { "lifecycle_only": false } }
|
||||
],
|
||||
"expected_current_turn_id": "bool-false-newer"
|
||||
},
|
||||
{
|
||||
"name": "lifecycle_only number 0 keeps the newer turn eligible",
|
||||
"turns": [
|
||||
{ "id": "num-zero-older", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": null, "metadata": {} },
|
||||
{ "id": "num-zero-newer", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T11:00:00Z", "completed_at": null, "metadata": { "lifecycle_only": 0 } }
|
||||
],
|
||||
"expected_current_turn_id": "num-zero-newer"
|
||||
},
|
||||
{
|
||||
"name": "lifecycle_only empty string keeps the newer turn eligible",
|
||||
"turns": [
|
||||
{ "id": "empty-str-older", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": null, "metadata": {} },
|
||||
{ "id": "empty-str-newer", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T11:00:00Z", "completed_at": null, "metadata": { "lifecycle_only": "" } }
|
||||
],
|
||||
"expected_current_turn_id": "empty-str-newer"
|
||||
},
|
||||
{
|
||||
"name": "lifecycle_only string false keeps the newer turn eligible",
|
||||
"turns": [
|
||||
{ "id": "str-false-older", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": null, "metadata": {} },
|
||||
{ "id": "str-false-newer", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T11:00:00Z", "completed_at": null, "metadata": { "lifecycle_only": "false" } }
|
||||
],
|
||||
"expected_current_turn_id": "str-false-newer"
|
||||
},
|
||||
{
|
||||
"name": "lifecycle_only string 0 keeps the newer turn eligible",
|
||||
"turns": [
|
||||
{ "id": "str-zero-older", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": null, "metadata": {} },
|
||||
{ "id": "str-zero-newer", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T11:00:00Z", "completed_at": null, "metadata": { "lifecycle_only": "0" } }
|
||||
],
|
||||
"expected_current_turn_id": "str-zero-newer"
|
||||
},
|
||||
{
|
||||
"name": "lifecycle_only string yes keeps the newer turn eligible",
|
||||
"turns": [
|
||||
{ "id": "yes-older", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": null, "metadata": {} },
|
||||
{ "id": "yes-newer", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T11:00:00Z", "completed_at": null, "metadata": { "lifecycle_only": "yes" } }
|
||||
],
|
||||
"expected_current_turn_id": "yes-newer"
|
||||
},
|
||||
{
|
||||
"name": "lifecycle_only empty object keeps the newer turn eligible",
|
||||
"turns": [
|
||||
{ "id": "obj-older", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": null, "metadata": {} },
|
||||
{ "id": "obj-newer", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T11:00:00Z", "completed_at": null, "metadata": { "lifecycle_only": {} } }
|
||||
],
|
||||
"expected_current_turn_id": "obj-newer"
|
||||
},
|
||||
{
|
||||
"name": "lifecycle_only empty array keeps the newer turn eligible",
|
||||
"turns": [
|
||||
{ "id": "arr-older", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": null, "metadata": {} },
|
||||
{ "id": "arr-newer", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T11:00:00Z", "completed_at": null, "metadata": { "lifecycle_only": [] } }
|
||||
],
|
||||
"expected_current_turn_id": "arr-newer"
|
||||
},
|
||||
{
|
||||
"name": "a session with only lifecycle turns resolves no current turn",
|
||||
"turns": [
|
||||
{ "id": "all-lifecycle-a", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": null, "metadata": { "lifecycle_only": true } },
|
||||
{ "id": "all-lifecycle-b", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T11:00:00Z", "completed_at": "2026-08-14T11:00:00Z", "metadata": { "lifecycle_only": "1" } }
|
||||
],
|
||||
"expected_current_turn_id": null
|
||||
},
|
||||
{
|
||||
"name": "an open turn beats a later completed turn",
|
||||
"turns": [
|
||||
{ "id": "order-open", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": null, "metadata": {} },
|
||||
{ "id": "order-completed-later", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T11:00:00Z", "completed_at": "2026-08-14T11:05:00Z", "metadata": {} }
|
||||
],
|
||||
"expected_current_turn_id": "order-open"
|
||||
},
|
||||
{
|
||||
"name": "later started_at wins among completed turns",
|
||||
"turns": [
|
||||
{ "id": "order-started-later", "started_at": "2026-08-14T11:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": "2026-08-14T12:00:00Z", "metadata": {} },
|
||||
{ "id": "order-started-earlier", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": "2026-08-14T12:00:00Z", "metadata": {} }
|
||||
],
|
||||
"expected_current_turn_id": "order-started-later"
|
||||
},
|
||||
{
|
||||
"name": "tie on started_at broken by later created_at",
|
||||
"turns": [
|
||||
{ "id": "order-created-later", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:01:00Z", "completed_at": "2026-08-14T12:00:00Z", "metadata": {} },
|
||||
{ "id": "order-created-earlier", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": "2026-08-14T12:00:00Z", "metadata": {} }
|
||||
],
|
||||
"expected_current_turn_id": "order-created-later"
|
||||
},
|
||||
{
|
||||
"name": "tie on started_at and created_at broken by higher id",
|
||||
"turns": [
|
||||
{ "id": "order-tie-2", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": "2026-08-14T12:00:00Z", "metadata": {} },
|
||||
{ "id": "order-tie-1", "started_at": "2026-08-14T10:00:00Z", "created_at": "2026-08-14T10:00:00Z", "completed_at": "2026-08-14T12:00:00Z", "metadata": {} }
|
||||
],
|
||||
"expected_current_turn_id": "order-tie-2"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -59,6 +59,33 @@ func turnDispatchAttemptedPredicate(driverName, turnAlias string) string {
|
||||
)
|
||||
}
|
||||
|
||||
// currentTurnAuthority composes turnAuthorityPredicate with R1's lifecycle
|
||||
// exclusion into predicate, and D1's total order into orderBy. Every
|
||||
// current-turn resolution site takes both values from this one call and
|
||||
// hand-writes neither (AC-3).
|
||||
//
|
||||
// predicate is a single parenthesized conjunct for `WHERE ... AND %s`.
|
||||
// orderBy is a bare, turnAlias-qualified expression list with no ORDER BY
|
||||
// keyword and no LIMIT, so it drops into both `ORDER BY %s` and
|
||||
// `ROW_NUMBER() OVER (... ORDER BY %s)`. Its first key ranks an open turn
|
||||
// (completed_at IS NULL) ahead of a completed one regardless of timestamps;
|
||||
// the remaining keys break ties by started_at, created_at, then id — all
|
||||
// descending, with id (the primary key) providing a total order.
|
||||
func currentTurnAuthority(driverName, turnAlias string) (predicate, orderBy string) {
|
||||
authority := turnAuthorityPredicate(driverName, turnAlias)
|
||||
lifecycle := turnLifecycleOnlyPredicate(driverName, turnAlias)
|
||||
predicate = fmt.Sprintf("(%s AND NOT (%s))", authority, lifecycle)
|
||||
orderBy = fmt.Sprintf(
|
||||
"CASE WHEN %s.completed_at IS NULL THEN 0 ELSE 1 END ASC, %s.started_at DESC, %s.created_at DESC, %s.id DESC",
|
||||
turnAlias, turnAlias, turnAlias, turnAlias,
|
||||
)
|
||||
return predicate, orderBy
|
||||
}
|
||||
|
||||
func turnLifecycleOnlyPredicate(driverName, turnAlias string) string {
|
||||
return turnMetadataFlagPredicate(driverName, turnAlias, models.TurnMetaKeyLifecycleOnly)
|
||||
}
|
||||
|
||||
func turnMetadataFlagPredicate(driverName, turnAlias, key string) string {
|
||||
value := dialect.JSONExtract(
|
||||
driverName,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
// turnResolutionFixture mirrors
|
||||
// testdata/current_turn_resolution.json (AC-10). It is the shared contract
|
||||
// between this Go repository test and the TypeScript unit test that loads
|
||||
// the same file from apps/web/lib/utils/pending-clarification-turn-authority.test.ts
|
||||
// (or a sibling file it imports) - one artifact both implementations are
|
||||
// proven to agree with, not two independently-trusted ones.
|
||||
type turnResolutionFixture struct {
|
||||
Cases []turnResolutionCase `json:"cases"`
|
||||
}
|
||||
|
||||
type turnResolutionCase struct {
|
||||
Name string `json:"name"`
|
||||
Turns []turnResolutionTurn `json:"turns"`
|
||||
ExpectedCurrentTurnID *string `json:"expected_current_turn_id"`
|
||||
}
|
||||
|
||||
type turnResolutionTurn struct {
|
||||
ID string `json:"id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
// loadTurnResolutionFixture SHALL fail the test (not skip) when the fixture
|
||||
// is missing, unreadable, or unparseable - a fixture that silently stops
|
||||
// being read is indistinguishable from two implementations that agree.
|
||||
func loadTurnResolutionFixture(t *testing.T) turnResolutionFixture {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("testdata/current_turn_resolution.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read current_turn_resolution.json: %v", err)
|
||||
}
|
||||
var fixture turnResolutionFixture
|
||||
if err := json.Unmarshal(raw, &fixture); err != nil {
|
||||
t.Fatalf("parse current_turn_resolution.json: %v", err)
|
||||
}
|
||||
if len(fixture.Cases) == 0 {
|
||||
t.Fatal("current_turn_resolution.json has no cases")
|
||||
}
|
||||
return fixture
|
||||
}
|
||||
|
||||
// resolveCurrentTurnID runs currentTurnAuthority's predicate/orderBy directly
|
||||
// against the turns table, the same CTE shape
|
||||
// FindActiveClarificationMessagesBySessionID uses internally, without its
|
||||
// join to messages - so it resolves a session's current turn regardless of
|
||||
// whether any message references it. Returns nil when no turn resolves.
|
||||
func resolveCurrentTurnID(t *testing.T, repo *Repository, sessionID string) *string {
|
||||
t.Helper()
|
||||
predicate, orderBy := currentTurnAuthority(repo.ro.DriverName(), "turn_row")
|
||||
query := fmt.Sprintf(`
|
||||
SELECT turn_row.id
|
||||
FROM task_session_turns turn_row
|
||||
WHERE turn_row.task_session_id = ?
|
||||
AND %s
|
||||
ORDER BY %s
|
||||
LIMIT 1
|
||||
`, predicate, orderBy)
|
||||
row := repo.ro.QueryRowContext(context.Background(), repo.ro.Rebind(query), sessionID)
|
||||
var id string
|
||||
switch err := row.Scan(&id); {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil
|
||||
case err != nil:
|
||||
t.Fatalf("resolve current turn for session %s: %v", sessionID, err)
|
||||
return nil
|
||||
default:
|
||||
return &id
|
||||
}
|
||||
}
|
||||
|
||||
// TestCurrentTurnResolutionFixture is AC-10: every case in the shared
|
||||
// fixture, run against the real Go currentTurnAuthority resolution. The
|
||||
// TypeScript counterpart runs the identical cases through
|
||||
// newestDurableTurnId. Neither test may filter the shared cases; each may
|
||||
// add its own.
|
||||
func TestCurrentTurnResolutionFixture(t *testing.T) {
|
||||
fixture := loadTurnResolutionFixture(t)
|
||||
for i, tc := range fixture.Cases {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
repo := newRepoForSessionTests(t)
|
||||
ctx := context.Background()
|
||||
taskID := fmt.Sprintf("task-fixture-%d", i)
|
||||
sessionID := fmt.Sprintf("session-fixture-%d", i)
|
||||
seedSessionForTurns(t, repo, taskID, sessionID)
|
||||
|
||||
for _, turn := range tc.Turns {
|
||||
if err := repo.CreateTurn(ctx, &models.Turn{
|
||||
ID: turn.ID,
|
||||
TaskSessionID: sessionID,
|
||||
TaskID: taskID,
|
||||
StartedAt: turn.StartedAt,
|
||||
CreatedAt: turn.CreatedAt,
|
||||
CompletedAt: turn.CompletedAt,
|
||||
Metadata: turn.Metadata,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed fixture turn %s: %v", turn.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
got := resolveCurrentTurnID(t, repo, sessionID)
|
||||
switch {
|
||||
case tc.ExpectedCurrentTurnID == nil && got != nil:
|
||||
t.Fatalf("current turn = %s, want none to resolve", *got)
|
||||
case tc.ExpectedCurrentTurnID != nil && got == nil:
|
||||
t.Fatalf("current turn = none, want %s", *tc.ExpectedCurrentTurnID)
|
||||
case tc.ExpectedCurrentTurnID != nil && got != nil && *got != *tc.ExpectedCurrentTurnID:
|
||||
t.Fatalf("current turn = %s, want %s", *got, *tc.ExpectedCurrentTurnID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/testutil"
|
||||
)
|
||||
|
||||
// TestCurrentTurnResolutionFixturePostgres is AC-10/D9: the same shared
|
||||
// fixture TestCurrentTurnResolutionFixture proves against SQLite, run
|
||||
// against PostgreSQL so D1's key 1 (open beats completed) and the
|
||||
// lifecycle_only exclusion resolve identically on both dialects. Skips
|
||||
// unless KANDEV_TEST_POSTGRES_DSN is set. Neither this test nor the SQLite
|
||||
// one may filter the shared cases; each may add its own.
|
||||
func TestCurrentTurnResolutionFixturePostgres(t *testing.T) {
|
||||
fixture := loadTurnResolutionFixture(t)
|
||||
for i, tc := range fixture.Cases {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
|
||||
repo, err := NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("init postgres schema: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
taskID := fmt.Sprintf("task-fixture-pg-%d", i)
|
||||
sessionID := fmt.Sprintf("session-fixture-pg-%d", i)
|
||||
seedSessionForTurns(t, repo, taskID, sessionID)
|
||||
|
||||
for _, turn := range tc.Turns {
|
||||
if err := repo.CreateTurn(ctx, &models.Turn{
|
||||
ID: turn.ID,
|
||||
TaskSessionID: sessionID,
|
||||
TaskID: taskID,
|
||||
StartedAt: turn.StartedAt,
|
||||
CreatedAt: turn.CreatedAt,
|
||||
CompletedAt: turn.CompletedAt,
|
||||
Metadata: turn.Metadata,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed fixture turn %s: %v", turn.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
got := resolveCurrentTurnID(t, repo, sessionID)
|
||||
switch {
|
||||
case tc.ExpectedCurrentTurnID == nil && got != nil:
|
||||
t.Fatalf("current turn = %s, want none to resolve", *got)
|
||||
case tc.ExpectedCurrentTurnID != nil && got == nil:
|
||||
t.Fatalf("current turn = none, want %s", *tc.ExpectedCurrentTurnID)
|
||||
case tc.ExpectedCurrentTurnID != nil && got != nil && *got != *tc.ExpectedCurrentTurnID:
|
||||
t.Fatalf("current turn = %s, want %s", *got, *tc.ExpectedCurrentTurnID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,146 @@ import (
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
// seedAuthorityTurn creates a turn with explicit started_at/created_at and an
|
||||
// optional completed_at, so a test can control every key of D1's total order
|
||||
// independently. A nil completedAt leaves the turn open.
|
||||
func seedAuthorityTurn(
|
||||
t *testing.T,
|
||||
repo *Repository,
|
||||
taskID, sessionID, turnID string,
|
||||
startedAt, createdAt time.Time,
|
||||
completedAt *time.Time,
|
||||
metadata map[string]interface{},
|
||||
) {
|
||||
t.Helper()
|
||||
if err := repo.CreateTurn(context.Background(), &models.Turn{
|
||||
ID: turnID, TaskID: taskID, TaskSessionID: sessionID,
|
||||
StartedAt: startedAt, CreatedAt: createdAt, CompletedAt: completedAt,
|
||||
Metadata: metadata,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed authority turn %s: %v", turnID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCurrentTurnAuthorityOrdering exercises D1's total order end-to-end
|
||||
// through FindActiveClarificationMessagesBySessionID, which applies
|
||||
// currentTurnAuthority with no extra completed_at filter (unlike
|
||||
// GetActiveTurnBySessionID) so both open and completed turns compete. Each
|
||||
// case seeds a pending clarification on exactly one of two candidate turns
|
||||
// and asserts whether that message survives, which is only true when its
|
||||
// turn resolves as current.
|
||||
func TestCurrentTurnAuthorityOrdering(t *testing.T) {
|
||||
base := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC)
|
||||
completed := base.Add(-time.Hour)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
winnerID string
|
||||
winnerStarted time.Time
|
||||
winnerCreated time.Time
|
||||
winnerDone *time.Time
|
||||
loserID string
|
||||
loserStarted time.Time
|
||||
loserCreated time.Time
|
||||
loserDone *time.Time
|
||||
}{
|
||||
{
|
||||
// AC-5/AC-6: an open turn outranks a completed one regardless of
|
||||
// which started earlier (R2). The completed turn here carries no
|
||||
// lifecycle_only marker, matching a pre-existing legacy row.
|
||||
name: "open turn beats later completed turn",
|
||||
winnerID: "turn-ord-open",
|
||||
winnerStarted: base,
|
||||
winnerCreated: base,
|
||||
winnerDone: nil,
|
||||
loserID: "turn-ord-completed-later",
|
||||
loserStarted: base.Add(time.Hour),
|
||||
loserCreated: base.Add(time.Hour),
|
||||
loserDone: &completed,
|
||||
},
|
||||
{
|
||||
name: "later started_at wins among completed turns",
|
||||
winnerID: "turn-ord-started-later",
|
||||
winnerStarted: base.Add(time.Hour),
|
||||
winnerCreated: base,
|
||||
winnerDone: &completed,
|
||||
loserID: "turn-ord-started-earlier",
|
||||
loserStarted: base,
|
||||
loserCreated: base,
|
||||
loserDone: &completed,
|
||||
},
|
||||
{
|
||||
name: "tie on started_at broken by later created_at",
|
||||
winnerID: "turn-ord-created-later",
|
||||
winnerStarted: base,
|
||||
winnerCreated: base.Add(time.Minute),
|
||||
winnerDone: &completed,
|
||||
loserID: "turn-ord-created-earlier",
|
||||
loserStarted: base,
|
||||
loserCreated: base,
|
||||
loserDone: &completed,
|
||||
},
|
||||
{
|
||||
name: "tie on started_at and created_at broken by higher id",
|
||||
winnerID: "turn-ord-tie-2",
|
||||
winnerStarted: base,
|
||||
winnerCreated: base,
|
||||
winnerDone: &completed,
|
||||
loserID: "turn-ord-tie-1",
|
||||
loserStarted: base,
|
||||
loserCreated: base,
|
||||
loserDone: &completed,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := newRepoForSessionTests(t)
|
||||
ctx := context.Background()
|
||||
taskID := "task-" + tt.name
|
||||
sessionID := "session-" + tt.name
|
||||
seedSessionForTurns(t, repo, taskID, sessionID)
|
||||
|
||||
seedAuthorityTurn(t, repo, taskID, sessionID, tt.winnerID, tt.winnerStarted, tt.winnerCreated, tt.winnerDone, nil)
|
||||
seedAuthorityTurn(t, repo, taskID, sessionID, tt.loserID, tt.loserStarted, tt.loserCreated, tt.loserDone, nil)
|
||||
insertClarificationMessage(t, repo, "msg-"+tt.winnerID, sessionID, taskID, tt.winnerID, "pending-"+tt.winnerID, "q1", "pending", 0, tt.winnerStarted)
|
||||
insertClarificationMessage(t, repo, "msg-"+tt.loserID, sessionID, taskID, tt.loserID, "pending-"+tt.loserID, "q1", "pending", 0, tt.loserStarted)
|
||||
|
||||
messages, err := repo.FindActiveClarificationMessagesBySessionID(ctx, sessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveClarificationMessagesBySessionID: %v", err)
|
||||
}
|
||||
if len(messages) != 1 || messages[0].TurnID != tt.winnerID {
|
||||
t.Fatalf("messages = %+v, want exactly the winner turn %s's message", messages, tt.winnerID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCurrentTurnAuthorityTwoOpenTurnsResolvesDeterministically covers D2's
|
||||
// observed half: at most one open turn per session is an invariant the
|
||||
// repository does not enforce with a constraint, so resolution must still
|
||||
// behave deterministically (not error) if it is ever violated. With both
|
||||
// candidates open, D1's first key ties and the remaining keys (started_at,
|
||||
// created_at, id, all descending) pick the winner exactly as they would for
|
||||
// two completed turns.
|
||||
func TestCurrentTurnAuthorityTwoOpenTurnsResolvesDeterministically(t *testing.T) {
|
||||
repo := newRepoForSessionTests(t)
|
||||
ctx := context.Background()
|
||||
const taskID = "task-two-open-turns"
|
||||
const sessionID = "session-two-open-turns"
|
||||
seedSessionForTurns(t, repo, taskID, sessionID)
|
||||
base := time.Date(2026, time.August, 21, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
seedAuthorityTurn(t, repo, taskID, sessionID, "turn-open-older", base, base, nil, nil)
|
||||
seedAuthorityTurn(t, repo, taskID, sessionID, "turn-open-newer", base.Add(time.Minute), base.Add(time.Minute), nil, nil)
|
||||
|
||||
active, err := repo.GetActiveTurnBySessionID(ctx, sessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActiveTurnBySessionID: %v", err)
|
||||
}
|
||||
if active == nil || active.ID != "turn-open-newer" {
|
||||
t.Fatalf("active turn = %#v, want turn-open-newer (later started_at wins deterministically)", active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnAuthorityToleratesStringFlagEncodings(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
// TestListTurnsBySessionKeepsLifecycleTurnVisible is AC-11's predicate half:
|
||||
// turnHistoryPredicate SHALL keep including lifecycle turns in visible
|
||||
// history, so the boot message stays grouped under its own turn in the
|
||||
// transcript, even though currentTurnAuthority now excludes that same turn
|
||||
// from current-turn resolution (AC-2/AC-3). GET .../turns
|
||||
// (internal/task/handlers/task_http_handlers.go) calls ListTurnsBySession
|
||||
// directly and DTO-converts every row it returns with no further filtering,
|
||||
// so a lifecycle turn present here is a lifecycle turn present in that
|
||||
// response.
|
||||
func TestListTurnsBySessionKeepsLifecycleTurnVisible(t *testing.T) {
|
||||
repo := newRepoForSessionTests(t)
|
||||
ctx := context.Background()
|
||||
seedSessionForTurns(t, repo, "task-turn-history-lifecycle", "session-turn-history-lifecycle")
|
||||
|
||||
base := time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC)
|
||||
lifecycleTurn := &models.Turn{
|
||||
ID: "turn-lifecycle-history",
|
||||
TaskSessionID: "session-turn-history-lifecycle",
|
||||
TaskID: "task-turn-history-lifecycle",
|
||||
StartedAt: base,
|
||||
CreatedAt: base,
|
||||
CompletedAt: &base,
|
||||
Metadata: map[string]interface{}{models.TurnMetaKeyLifecycleOnly: true},
|
||||
}
|
||||
conversationalTurn := &models.Turn{
|
||||
ID: "turn-conversational-history",
|
||||
TaskSessionID: "session-turn-history-lifecycle",
|
||||
TaskID: "task-turn-history-lifecycle",
|
||||
StartedAt: base.Add(time.Minute),
|
||||
CreatedAt: base.Add(time.Minute),
|
||||
}
|
||||
for _, turn := range []*models.Turn{lifecycleTurn, conversationalTurn} {
|
||||
if err := repo.CreateTurn(ctx, turn); err != nil {
|
||||
t.Fatalf("CreateTurn(%s): %v", turn.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
turns, err := repo.ListTurnsBySession(ctx, "session-turn-history-lifecycle")
|
||||
if err != nil {
|
||||
t.Fatalf("ListTurnsBySession: %v", err)
|
||||
}
|
||||
|
||||
ids := make(map[string]*models.Turn, len(turns))
|
||||
for _, turn := range turns {
|
||||
ids[turn.ID] = turn
|
||||
}
|
||||
if len(turns) != 2 {
|
||||
t.Fatalf("ListTurnsBySession returned %d turns %+v, want both the lifecycle and conversational turn", len(turns), turns)
|
||||
}
|
||||
got, ok := ids[lifecycleTurn.ID]
|
||||
if !ok {
|
||||
t.Fatalf("ListTurnsBySession dropped the lifecycle turn %q; turnHistoryPredicate must keep it visible", lifecycleTurn.ID)
|
||||
}
|
||||
if lifecycleOnly, ok := got.Metadata[models.TurnMetaKeyLifecycleOnly]; !ok || lifecycleOnly != true {
|
||||
t.Fatalf("returned lifecycle turn metadata[%q] = %v, want true", models.TurnMetaKeyLifecycleOnly, lifecycleOnly)
|
||||
}
|
||||
if _, ok := ids[conversationalTurn.ID]; !ok {
|
||||
t.Fatalf("ListTurnsBySession dropped the conversational turn %q", conversationalTurn.ID)
|
||||
}
|
||||
}
|
||||
@@ -276,6 +276,8 @@ func (s *Service) createCompletedTurn(ctx context.Context, session *models.TaskS
|
||||
return nil, errors.New("cannot create completed turn without a session")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
metadata := turnStartRuntimeMetadata(session)
|
||||
metadata[models.TurnMetaKeyLifecycleOnly] = true
|
||||
turn := &models.Turn{
|
||||
ID: uuid.New().String(),
|
||||
TaskSessionID: session.ID,
|
||||
@@ -284,7 +286,7 @@ func (s *Service) createCompletedTurn(ctx context.Context, session *models.TaskS
|
||||
RouteGeneration: session.RouteGeneration,
|
||||
StartedAt: now,
|
||||
CompletedAt: &now,
|
||||
Metadata: turnStartRuntimeMetadata(session),
|
||||
Metadata: metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
// newLifecycleShadowTestService seeds one workspace/workflow/task/session so
|
||||
// AC-4's scenario has a real session to hang turns and messages off.
|
||||
func newLifecycleShadowTestService(t *testing.T) (*Service, *sqliterepo.Repository, string, string) {
|
||||
t.Helper()
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
const wsID, wfID, taskID, sessionID = "ws-cta", "wf-cta", "task-cta", "sess-cta"
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: wsID, Name: "CTA"}); err != nil {
|
||||
t.Fatalf("create workspace: %v", err)
|
||||
}
|
||||
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: wfID, WorkspaceID: wsID, Name: "flow"}); err != nil {
|
||||
t.Fatalf("create workflow: %v", err)
|
||||
}
|
||||
if err := repo.CreateTask(ctx, &models.Task{
|
||||
ID: taskID, WorkspaceID: wsID, WorkflowID: wfID, WorkflowStepID: "step-1",
|
||||
Title: "CTA", State: v1.TaskStateCreated, Priority: "medium",
|
||||
}); err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
|
||||
ID: sessionID, TaskID: taskID, State: models.TaskSessionStateCreated,
|
||||
}); err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
return svc, repo, taskID, sessionID
|
||||
}
|
||||
|
||||
// TestCurrentTurnAuthoritySurvivesLifecycleShadowOverPendingClarification is
|
||||
// AC-4: a near-simultaneous lifecycle turn created through the real
|
||||
// Service.CreateMessage(CompletedTurn: true) write path must never shadow a
|
||||
// genuinely open turn holding a pending clarification, across every named
|
||||
// consumer surface (list_pending_questions_kandev, GetPendingActionsBySessionIDs,
|
||||
// and internal/clarification's respond path). Verified at the service and
|
||||
// repository layers per the spec's own scoping, not browser-level: the
|
||||
// respond surface is exercised through CompleteActiveClarificationBundle,
|
||||
// the exact repository method internal/clarification's resolver calls.
|
||||
func TestCurrentTurnAuthoritySurvivesLifecycleShadowOverPendingClarification(t *testing.T) {
|
||||
svc, repo, taskID, sessionID := newLifecycleShadowTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// The conversational turn is seeded already-completed (its agent turn
|
||||
// finished; the clarification it raised is still unanswered) and MUST
|
||||
// start in the past: createCompletedTurn stamps time.Now().UTC(), so an
|
||||
// open conversational turn would already win D1's open-beats-completed
|
||||
// tie-break for the wrong reason (R2), without ever exercising R1's
|
||||
// lifecycle_only exclusion. Only pitting two completed turns against each
|
||||
// other - where D1's ordering alone would let the later (synthetic) one
|
||||
// win - proves the exclusion is what saves the message.
|
||||
past := time.Now().UTC().Add(-time.Hour)
|
||||
const conversationTurnID = "turn-conversation"
|
||||
if err := repo.CreateTurn(ctx, &models.Turn{
|
||||
ID: conversationTurnID, TaskSessionID: sessionID, TaskID: taskID,
|
||||
StartedAt: past, CreatedAt: past, CompletedAt: &past, UpdatedAt: past,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed conversational turn: %v", err)
|
||||
}
|
||||
|
||||
const pendingID = "pending-cta"
|
||||
if err := repo.CreateMessage(ctx, &models.Message{
|
||||
ID: "msg-cta-question", TaskSessionID: sessionID, TaskID: taskID,
|
||||
TurnID: conversationTurnID, AuthorType: models.MessageAuthorAgent,
|
||||
Type: models.MessageTypeClarificationRequest,
|
||||
Metadata: map[string]interface{}{
|
||||
"pending_id": pendingID,
|
||||
"question_id": "q1",
|
||||
"status": "pending",
|
||||
"question_index": 0,
|
||||
"question_total": 1,
|
||||
"question": map[string]interface{}{
|
||||
"id": "q1",
|
||||
"title": "title",
|
||||
"prompt": "prompt",
|
||||
"options": []map[string]interface{}{
|
||||
{"option_id": "opt1", "label": "Yes"},
|
||||
},
|
||||
},
|
||||
},
|
||||
CreatedAt: past,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed pending clarification message: %v", err)
|
||||
}
|
||||
|
||||
// R1: reproduce the defect's trigger through the real write path - an
|
||||
// agent resume creates a synthetic completed turn, never an inserted row
|
||||
// imitating one.
|
||||
if _, err := svc.CreateMessage(ctx, &CreateMessageRequest{
|
||||
TaskSessionID: sessionID,
|
||||
Content: "resumed",
|
||||
CompletedTurn: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create lifecycle turn via CreateMessage: %v", err)
|
||||
}
|
||||
|
||||
t.Run("list_pending_questions_kandev surface", func(t *testing.T) {
|
||||
page, err := repo.ListUnresolvedClarificationBundles(ctx, models.ListClarificationBundlesOptions{
|
||||
Unscoped: true, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListUnresolvedClarificationBundles: %v", err)
|
||||
}
|
||||
if len(page.Bundles) != 1 || page.Bundles[0].PendingID != pendingID {
|
||||
t.Fatalf("bundles = %+v, want exactly pending bundle %s", page.Bundles, pendingID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetPendingActionsBySessionIDs surface", func(t *testing.T) {
|
||||
actions, err := repo.GetPendingActionsBySessionIDs(ctx, []string{sessionID})
|
||||
if err != nil {
|
||||
t.Fatalf("GetPendingActionsBySessionIDs: %v", err)
|
||||
}
|
||||
if actions[sessionID] != models.TaskPendingActionClarification {
|
||||
t.Fatalf("pending action for %s = %v, want %v", sessionID, actions[sessionID], models.TaskPendingActionClarification)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("internal/clarification respond surface", func(t *testing.T) {
|
||||
completed, claimed, err := repo.CompleteActiveClarificationBundle(ctx, pendingID, "answered", map[string]interface{}{
|
||||
"q1": "opt1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteActiveClarificationBundle: %v", err)
|
||||
}
|
||||
if !claimed || len(completed) != 1 {
|
||||
t.Fatalf("claimed=%v completed=%+v, want a single successful claim, not a conflict", claimed, completed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCurrentTurnAuthoritySurvivesLifecycleShadowOverPendingClarificationOpenTurn
|
||||
// covers AC-4's literal precondition: the real conversational turn is
|
||||
// genuinely OPEN (never completed) when the synthetic lifecycle turn is
|
||||
// created after it. TestCurrentTurnAuthoritySurvivesLifecycleShadowOverPendingClarification
|
||||
// deliberately completes the conversational turn to isolate R1's
|
||||
// lifecycle_only exclusion from D1's open-beats-completed tie-break; this
|
||||
// test instead exercises the scenario as it actually happens in production -
|
||||
// an agent resume racing a still-open, awaiting-input turn - across the same
|
||||
// three named consumer surfaces.
|
||||
func TestCurrentTurnAuthoritySurvivesLifecycleShadowOverPendingClarificationOpenTurn(t *testing.T) {
|
||||
svc, repo, taskID, sessionID := newLifecycleShadowTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
past := time.Now().UTC().Add(-time.Hour)
|
||||
const conversationTurnID = "turn-conversation-open"
|
||||
if err := repo.CreateTurn(ctx, &models.Turn{
|
||||
ID: conversationTurnID, TaskSessionID: sessionID, TaskID: taskID,
|
||||
StartedAt: past, CreatedAt: past, UpdatedAt: past,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed open conversational turn: %v", err)
|
||||
}
|
||||
|
||||
const pendingID = "pending-cta-open"
|
||||
if err := repo.CreateMessage(ctx, &models.Message{
|
||||
ID: "msg-cta-open-question", TaskSessionID: sessionID, TaskID: taskID,
|
||||
TurnID: conversationTurnID, AuthorType: models.MessageAuthorAgent,
|
||||
Type: models.MessageTypeClarificationRequest,
|
||||
Metadata: map[string]interface{}{
|
||||
"pending_id": pendingID,
|
||||
"question_id": "q1",
|
||||
"status": "pending",
|
||||
"question_index": 0,
|
||||
"question_total": 1,
|
||||
"question": map[string]interface{}{
|
||||
"id": "q1",
|
||||
"title": "title",
|
||||
"prompt": "prompt",
|
||||
"options": []map[string]interface{}{
|
||||
{"option_id": "opt1", "label": "Yes"},
|
||||
},
|
||||
},
|
||||
},
|
||||
CreatedAt: past,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed pending clarification message: %v", err)
|
||||
}
|
||||
|
||||
// R1/D5: reproduce the defect's trigger through the real write path - an
|
||||
// agent resume creates a synthetic completed turn after the real turn,
|
||||
// while the real turn is still open and awaiting the clarification's
|
||||
// answer.
|
||||
if _, err := svc.CreateMessage(ctx, &CreateMessageRequest{
|
||||
TaskSessionID: sessionID,
|
||||
Content: "resumed",
|
||||
CompletedTurn: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create lifecycle turn via CreateMessage: %v", err)
|
||||
}
|
||||
|
||||
t.Run("list_pending_questions_kandev surface", func(t *testing.T) {
|
||||
page, err := repo.ListUnresolvedClarificationBundles(ctx, models.ListClarificationBundlesOptions{
|
||||
Unscoped: true, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListUnresolvedClarificationBundles: %v", err)
|
||||
}
|
||||
if len(page.Bundles) != 1 || page.Bundles[0].PendingID != pendingID {
|
||||
t.Fatalf("bundles = %+v, want exactly pending bundle %s", page.Bundles, pendingID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetPendingActionsBySessionIDs surface", func(t *testing.T) {
|
||||
actions, err := repo.GetPendingActionsBySessionIDs(ctx, []string{sessionID})
|
||||
if err != nil {
|
||||
t.Fatalf("GetPendingActionsBySessionIDs: %v", err)
|
||||
}
|
||||
if actions[sessionID] != models.TaskPendingActionClarification {
|
||||
t.Fatalf("pending action for %s = %v, want %v", sessionID, actions[sessionID], models.TaskPendingActionClarification)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("internal/clarification respond surface", func(t *testing.T) {
|
||||
completed, claimed, err := repo.CompleteActiveClarificationBundle(ctx, pendingID, "answered", map[string]interface{}{
|
||||
"q1": "opt1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteActiveClarificationBundle: %v", err)
|
||||
}
|
||||
if !claimed || len(completed) != 1 {
|
||||
t.Fatalf("claimed=%v completed=%+v, want a single successful claim, not a conflict", claimed, completed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBootResumeLifecycleTurnNeverClaimsClarificationRequest is AC-7 / D5: the
|
||||
// boot-on-resume path (Service.CreateMessage with CompletedTurn: true,
|
||||
// Type: "script_execution", mirroring bootMsgAdapter.CreateMessage in
|
||||
// internal/backendapp/worktree.go) creates a lifecycle turn, but a
|
||||
// clarification_request written afterward through the real getOrStartTurn
|
||||
// path must still land on the open conversational turn, never the lifecycle
|
||||
// one - regardless of the lifecycle turn's later timestamp.
|
||||
func TestBootResumeLifecycleTurnNeverClaimsClarificationRequest(t *testing.T) {
|
||||
svc, repo, taskID, sessionID := newLifecycleShadowTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
past := time.Now().UTC().Add(-time.Hour)
|
||||
const conversationTurnID = "turn-conversation-ac7"
|
||||
if err := repo.CreateTurn(ctx, &models.Turn{
|
||||
ID: conversationTurnID, TaskSessionID: sessionID, TaskID: taskID,
|
||||
StartedAt: past, CreatedAt: past, UpdatedAt: past,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed open conversational turn: %v", err)
|
||||
}
|
||||
|
||||
bootMsg, err := svc.CreateMessage(ctx, &CreateMessageRequest{
|
||||
TaskSessionID: sessionID,
|
||||
Content: "",
|
||||
AuthorType: "agent",
|
||||
Type: "script_execution",
|
||||
CompletedTurn: true,
|
||||
Metadata: map[string]interface{}{
|
||||
"script_type": "agent_boot",
|
||||
"status": "running",
|
||||
"is_resuming": true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create boot message via CompletedTurn path: %v", err)
|
||||
}
|
||||
if bootMsg.TurnID == conversationTurnID {
|
||||
t.Fatalf("boot message landed on the conversational turn %s, want a fresh lifecycle turn", conversationTurnID)
|
||||
}
|
||||
lifecycleTurnID := bootMsg.TurnID
|
||||
|
||||
clarification, err := svc.CreateMessage(ctx, &CreateMessageRequest{
|
||||
TaskSessionID: sessionID,
|
||||
AuthorType: "agent",
|
||||
Type: string(models.MessageTypeClarificationRequest),
|
||||
Content: "q",
|
||||
Metadata: map[string]interface{}{
|
||||
"pending_id": "pending-ac7",
|
||||
"question_id": "q1",
|
||||
"status": "pending",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create clarification_request after boot resume: %v", err)
|
||||
}
|
||||
if clarification.TurnID != conversationTurnID {
|
||||
t.Fatalf("clarification_request turn_id = %s, want the open conversational turn %s", clarification.TurnID, conversationTurnID)
|
||||
}
|
||||
if clarification.TurnID == lifecycleTurnID {
|
||||
t.Fatalf("clarification_request landed on the lifecycle turn %s, want the open conversational turn", lifecycleTurnID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateMessageRequestCompletedTurnNotJSONSettable is AC-7's second half:
|
||||
// CreateMessageRequest.CompletedTurn is tagged json:"-" so no API client can
|
||||
// set it. Unmarshalling a body that sets completed_turn SHALL leave the field
|
||||
// false; a caller later making it settable while writing a
|
||||
// clarification_request would let any client hide its own question.
|
||||
func TestCreateMessageRequestCompletedTurnNotJSONSettable(t *testing.T) {
|
||||
var req CreateMessageRequest
|
||||
body := []byte(`{"session_id":"sess","content":"hi","completed_turn":true}`)
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if req.CompletedTurn {
|
||||
t.Fatal("CompletedTurn = true after unmarshalling a body setting completed_turn, want false (json:\"-\")")
|
||||
}
|
||||
}
|
||||
@@ -255,3 +255,26 @@ func TestCreateCompletedTurnCarriesSameStamp(t *testing.T) {
|
||||
t.Fatalf("synthetic completed turn stamp = %v, want %q", turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart], "step-c")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCompletedTurnMarksLifecycleOnly(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
setupStepStampTask(t, repo, "task-lifecycle-only", "step-d")
|
||||
session := createStepStampSession(t, repo, "session-lifecycle-only", "task-lifecycle-only")
|
||||
|
||||
turn, err := svc.createCompletedTurn(ctx, session)
|
||||
if err != nil {
|
||||
t.Fatalf("createCompletedTurn: %v", err)
|
||||
}
|
||||
if turn.Metadata[models.TurnMetaKeyLifecycleOnly] != true {
|
||||
t.Fatalf("synthetic completed turn lifecycle_only = %v, want true", turn.Metadata[models.TurnMetaKeyLifecycleOnly])
|
||||
}
|
||||
|
||||
stored, err := repo.GetTurn(ctx, turn.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTurn: %v", err)
|
||||
}
|
||||
if stored.Metadata[models.TurnMetaKeyLifecycleOnly] != true {
|
||||
t.Fatalf("persisted completed turn lifecycle_only = %v, want true", stored.Metadata[models.TurnMetaKeyLifecycleOnly])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1324,6 +1324,13 @@ export class ApiClient {
|
||||
* metadata dialog's `turn_metadata` field. `authorType` defaults to agent;
|
||||
* "user" seeds a prompt row whose prompt_index is computed server-side.
|
||||
* `createdAt` (RFC3339) pins the row's timestamp for deterministic ordering.
|
||||
*
|
||||
* `newTurn` creates a brand-new turn for this message instead of reusing
|
||||
* the session's active turn, so a spec can hold a real open turn on the
|
||||
* session while seeding a second, independently-timed turn alongside it
|
||||
* (e.g. a lifecycle-shaped turn that must not shadow the open one).
|
||||
* `turnStartedAt`/`turnCompletedAt` (RFC3339) set that new turn's
|
||||
* timestamps; both are ignored unless `newTurn` is true.
|
||||
*/
|
||||
async seedSessionMessage(
|
||||
sessionId: string,
|
||||
@@ -1334,6 +1341,9 @@ export class ApiClient {
|
||||
turnMetadata?: Record<string, unknown>;
|
||||
authorType?: "user" | "agent";
|
||||
createdAt?: string;
|
||||
newTurn?: boolean;
|
||||
turnStartedAt?: string;
|
||||
turnCompletedAt?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const body: Record<string, unknown> = { session_id: sessionId, type: opts.type };
|
||||
@@ -1342,6 +1352,9 @@ export class ApiClient {
|
||||
if (opts.turnMetadata !== undefined) body.turn_metadata = opts.turnMetadata;
|
||||
if (opts.authorType !== undefined) body.author_type = opts.authorType;
|
||||
if (opts.createdAt !== undefined) body.created_at = opts.createdAt;
|
||||
if (opts.newTurn !== undefined) body.new_turn = opts.newTurn;
|
||||
if (opts.turnStartedAt !== undefined) body.turn_started_at = opts.turnStartedAt;
|
||||
if (opts.turnCompletedAt !== undefined) body.turn_completed_at = opts.turnCompletedAt;
|
||||
await this.request("POST", "/api/v1/_test/messages", body);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Regression guard for docs/specs/current-turn-authority/spec.md: a
|
||||
// near-simultaneous duplicate task_session_turns row (the synthetic
|
||||
// "lifecycle" turn Service.createCompletedTurn writes on agent resume) must
|
||||
// never outrank a session's real open turn when the UI resolves which turn
|
||||
// is "current" — otherwise a pending clarification on the open turn is
|
||||
// silently hidden from the chat overlay. Reproduces Scenarios 1 and 3 from
|
||||
// the spec via the e2e test harness (real turn resolution end to end, not
|
||||
// just the pure newestDurableTurnId unit test AC-10 already covers).
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import { watchWs, waitForHttp } from "../../helpers/causal-waits";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
/** Matches GET /api/v1/task-sessions/:id/turns, the REST snapshot that backs
|
||||
* ensureSessionTurnsLoaded — a separate, fire-and-forget fetch from the WS
|
||||
* message.list round trip (use-session-messages.ts fires it `void` before
|
||||
* message.list is even awaited). The clarification overlay's turn-scoping
|
||||
* depends on this fetch too, so a test must wait for both. */
|
||||
const TURNS_FETCH_PATH = /\/task-sessions\/[^/]+\/turns/;
|
||||
|
||||
const QUESTION_PROMPT = "Which environment should I deploy to?";
|
||||
const STAGING_LABEL = "Staging";
|
||||
const PRODUCTION_LABEL = "Production";
|
||||
|
||||
/**
|
||||
* Seeds a session with a real turn carrying a pending clarification, plus a
|
||||
* second turn started 0.8s later that shadows it in "latest by started_at"
|
||||
* ordering. `markedLifecycle` selects between AC-9's marked
|
||||
* `lifecycle_only: true` shape (Scenario 1) and AC-6's unmarked legacy
|
||||
* zero-duration shape (Scenario 3) — both must resolve to turn A.
|
||||
*
|
||||
* AC-9's marked variant also completes turn A (instead of leaving it open),
|
||||
* forcing a D1 key-1 tie against turn B (also completed): with both turns
|
||||
* completed, D1's remaining keys (started_at DESC) alone would hand
|
||||
* authority to the later turn B, exactly as if R1's lifecycle_only exclusion
|
||||
* did not exist. Only R1 filtering B out of contention lets turn A win, so
|
||||
* this shape actually requires the exclusion mechanism — mirroring
|
||||
* TestCurrentTurnAuthoritySurvivesLifecycleShadowOverPendingClarification's
|
||||
* own precedent. AC-6's unmarked variant leaves turn A open, since that
|
||||
* scenario proves D1's plain open-beats-completed rule, not R1.
|
||||
*/
|
||||
async function seedShadowedClarification(
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
title: string,
|
||||
markedLifecycle: boolean,
|
||||
): Promise<{ taskId: string; sessionId: string }> {
|
||||
const task = await apiClient.createTask(seedData.workspaceId, title, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, {
|
||||
state: "WAITING_FOR_INPUT",
|
||||
});
|
||||
|
||||
const turnAStartedAt = new Date(Date.now() - 120_000).toISOString();
|
||||
const turnBStartedAt = new Date(Date.now() - 119_200).toISOString();
|
||||
|
||||
await apiClient.seedSessionMessage(sessionId, {
|
||||
type: "clarification_request",
|
||||
newTurn: true,
|
||||
turnStartedAt: turnAStartedAt,
|
||||
...(markedLifecycle ? { turnCompletedAt: turnAStartedAt } : {}),
|
||||
metadata: {
|
||||
pending_id: "pend-shadow-1",
|
||||
session_id: sessionId,
|
||||
question_id: "q1",
|
||||
question_index: 0,
|
||||
question_total: 1,
|
||||
status: "pending",
|
||||
question: {
|
||||
id: "q1",
|
||||
title: "Deploy target",
|
||||
prompt: QUESTION_PROMPT,
|
||||
options: [
|
||||
{ option_id: "opt-staging", label: STAGING_LABEL, description: "" },
|
||||
{ option_id: "opt-prod", label: PRODUCTION_LABEL, description: "" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await apiClient.seedSessionMessage(sessionId, {
|
||||
type: "script_execution",
|
||||
content: "Resuming session.",
|
||||
newTurn: true,
|
||||
turnStartedAt: turnBStartedAt,
|
||||
turnCompletedAt: turnBStartedAt,
|
||||
...(markedLifecycle ? { turnMetadata: { lifecycle_only: true } } : {}),
|
||||
});
|
||||
|
||||
return { taskId: task.id, sessionId };
|
||||
}
|
||||
|
||||
test.describe("Duplicate lifecycle turn does not hide a pending clarification", () => {
|
||||
test("a marked lifecycle turn tied with the clarification turn in D1 ordering does not shadow its clarification (AC-9)", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const { taskId } = await seedShadowedClarification(
|
||||
apiClient,
|
||||
seedData,
|
||||
"Lifecycle shadow - marked",
|
||||
true,
|
||||
);
|
||||
|
||||
const wsWatcher = watchWs(testPage);
|
||||
const messagesLoaded = wsWatcher.waitForResponse("message.list");
|
||||
const turnsLoaded = waitForHttp(testPage, "GET", TURNS_FETCH_PATH);
|
||||
await testPage.goto(`/t/${taskId}`);
|
||||
await Promise.all([messagesLoaded, turnsLoaded]);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
await expect(session.clarificationOverlay()).toBeVisible();
|
||||
await expect(session.clarificationOverlay()).toContainText(QUESTION_PROMPT);
|
||||
|
||||
const staging = session.clarificationOption(STAGING_LABEL);
|
||||
await expect(staging).toBeVisible();
|
||||
await staging.click();
|
||||
await expect(staging).toHaveAttribute("data-selected", "true");
|
||||
});
|
||||
|
||||
test("an unmarked legacy zero-duration turn over an open turn still resolves to the open turn (AC-6)", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const { taskId } = await seedShadowedClarification(
|
||||
apiClient,
|
||||
seedData,
|
||||
"Lifecycle shadow - unmarked legacy",
|
||||
false,
|
||||
);
|
||||
|
||||
const wsWatcher = watchWs(testPage);
|
||||
const messagesLoaded = wsWatcher.waitForResponse("message.list");
|
||||
const turnsLoaded = waitForHttp(testPage, "GET", TURNS_FETCH_PATH);
|
||||
await testPage.goto(`/t/${taskId}`);
|
||||
await Promise.all([messagesLoaded, turnsLoaded]);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
await expect(session.clarificationOverlay()).toBeVisible();
|
||||
await expect(session.clarificationOverlay()).toContainText(QUESTION_PROMPT);
|
||||
|
||||
const production = session.clarificationOption(PRODUCTION_LABEL);
|
||||
await expect(production).toBeVisible();
|
||||
await production.click();
|
||||
await expect(production).toHaveAttribute("data-selected", "true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sessionId as toSessionId, taskId as toTaskId, type Turn } from "@/lib/types/http";
|
||||
import { newestDurableTurnId } from "./pending-clarification";
|
||||
|
||||
// AC-10: this file and TestCurrentTurnResolutionFixture
|
||||
// (apps/backend/internal/task/repository/sqlite/turn_authority_fixture_test.go)
|
||||
// run the identical cases from one shared JSON fixture through the Go and
|
||||
// TypeScript current-turn resolutions. Neither test may filter the shared
|
||||
// cases; each may add its own. The path is resolved from this file via
|
||||
// import.meta.url, not the process working directory, which under Vitest is
|
||||
// apps/web rather than this file's directory. Building the path in two steps
|
||||
// (dirname, then join) rather than `new URL(relative, import.meta.url)`
|
||||
// avoids Vite's static asset-URL transform, which rewrites a
|
||||
// literal-relative-plus-import.meta.url expression pointing outside the
|
||||
// project root into an http://.../@fs/... dev-server URL instead of a real
|
||||
// file: URL.
|
||||
const FIXTURE_PATH = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../backend/internal/task/repository/sqlite/testdata/current_turn_resolution.json",
|
||||
);
|
||||
|
||||
type FixtureTurn = {
|
||||
id: string;
|
||||
started_at: string;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FixtureCase = {
|
||||
name: string;
|
||||
turns: FixtureTurn[];
|
||||
expected_current_turn_id: string | null;
|
||||
};
|
||||
|
||||
type Fixture = {
|
||||
cases: FixtureCase[];
|
||||
};
|
||||
|
||||
// loadFixture SHALL fail the test (not skip) when the fixture is missing or
|
||||
// unparseable - a fixture that silently stops being read is
|
||||
// indistinguishable from two implementations that agree.
|
||||
function loadFixture(): Fixture {
|
||||
const raw = readFileSync(FIXTURE_PATH, "utf8");
|
||||
const parsed = JSON.parse(raw) as Fixture;
|
||||
if (!parsed.cases?.length) {
|
||||
throw new Error(`${path.basename(FIXTURE_PATH)} has no cases`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// completed_at: null in the fixture maps to an absent property, matching the
|
||||
// wire contract (Turn.completed_at?: string); the production payload type is
|
||||
// not widened to admit null.
|
||||
function toTurn(fixtureTurn: FixtureTurn): Turn {
|
||||
const turn: Turn = {
|
||||
id: fixtureTurn.id,
|
||||
session_id: toSessionId("fixture-session"),
|
||||
task_id: toTaskId("fixture-task"),
|
||||
started_at: fixtureTurn.started_at,
|
||||
created_at: fixtureTurn.created_at,
|
||||
updated_at: fixtureTurn.created_at,
|
||||
metadata: fixtureTurn.metadata,
|
||||
};
|
||||
if (fixtureTurn.completed_at !== null) {
|
||||
turn.completed_at = fixtureTurn.completed_at;
|
||||
}
|
||||
return turn;
|
||||
}
|
||||
|
||||
const fixture = loadFixture();
|
||||
|
||||
describe("newestDurableTurnId AC-10: shared fixture agreement with the Go resolution", () => {
|
||||
for (const testCase of fixture.cases) {
|
||||
it(testCase.name, () => {
|
||||
const turns = testCase.turns.map(toTurn);
|
||||
expect(newestDurableTurnId(turns)).toBe(testCase.expected_current_turn_id);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sessionId as toSessionId, taskId as toTaskId, type Turn } from "@/lib/types/http";
|
||||
import { newestDurableTurnId } from "./pending-clarification";
|
||||
|
||||
// AC-9: newestDurableTurnId must apply the same two rules the backend's
|
||||
// currentTurnAuthority applies before its started_at/created_at/id
|
||||
// comparison - skip lifecycle turns, prefer an open turn over a completed
|
||||
// one - reading the marker from the turn payload rather than inferring
|
||||
// anything from timestamps.
|
||||
|
||||
const TIMESTAMP_11 = "2026-08-14T11:00:00Z";
|
||||
const TIMESTAMP_13 = "2026-08-14T13:00:00Z";
|
||||
const NEWER_TURN_ID = "turn-newer";
|
||||
|
||||
function turn(
|
||||
id: string,
|
||||
startedAt: string,
|
||||
createdAt = startedAt,
|
||||
overrides: Partial<Turn> = {},
|
||||
): Turn {
|
||||
return {
|
||||
id,
|
||||
session_id: toSessionId("session-1"),
|
||||
task_id: toTaskId("task-1"),
|
||||
started_at: startedAt,
|
||||
created_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("newestDurableTurnId AC-9: lifecycle exclusion and open-turn precedence", () => {
|
||||
it("skips a lifecycle-only turn even when it is the newest by timestamp", () => {
|
||||
expect(
|
||||
newestDurableTurnId([
|
||||
turn("turn-real", "2026-08-14T12:00:00Z"),
|
||||
turn("turn-lifecycle", TIMESTAMP_13, undefined, { metadata: { lifecycle_only: true } }),
|
||||
]),
|
||||
).toBe("turn-real");
|
||||
});
|
||||
|
||||
it("resolves to null when every turn is lifecycle-only", () => {
|
||||
expect(
|
||||
newestDurableTurnId([
|
||||
turn("turn-lifecycle", "2026-08-14T12:00:00Z", undefined, {
|
||||
metadata: { lifecycle_only: true },
|
||||
}),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
const lifecycleEncodings = [true, 1, "true", "1"];
|
||||
it.each(lifecycleEncodings)("treats metadata.lifecycle_only = %j as lifecycle", (value) => {
|
||||
expect(
|
||||
newestDurableTurnId([
|
||||
turn("turn-real", "2026-08-14T12:00:00Z"),
|
||||
turn("turn-lifecycle", TIMESTAMP_13, undefined, { metadata: { lifecycle_only: value } }),
|
||||
]),
|
||||
).toBe("turn-real");
|
||||
});
|
||||
|
||||
const conversationalEncodings = [null, undefined, false, 0, "", "false", "0", "yes", {}, []];
|
||||
it.each(conversationalEncodings)(
|
||||
"treats metadata.lifecycle_only = %j as conversational, not lifecycle",
|
||||
(value) => {
|
||||
expect(
|
||||
newestDurableTurnId([
|
||||
turn("turn-older", TIMESTAMP_11),
|
||||
turn(NEWER_TURN_ID, TIMESTAMP_13, undefined, { metadata: { lifecycle_only: value } }),
|
||||
]),
|
||||
).toBe(NEWER_TURN_ID);
|
||||
},
|
||||
);
|
||||
|
||||
it("treats an absent metadata.lifecycle_only key as conversational", () => {
|
||||
expect(
|
||||
newestDurableTurnId([turn("turn-older", TIMESTAMP_11), turn(NEWER_TURN_ID, TIMESTAMP_13)]),
|
||||
).toBe(NEWER_TURN_ID);
|
||||
});
|
||||
|
||||
it("prefers an open turn over a later completed turn", () => {
|
||||
expect(
|
||||
newestDurableTurnId([
|
||||
turn("turn-open", TIMESTAMP_11),
|
||||
turn("turn-completed-later", TIMESTAMP_13, undefined, {
|
||||
completed_at: "2026-08-14T13:05:00Z",
|
||||
}),
|
||||
]),
|
||||
).toBe("turn-open");
|
||||
});
|
||||
|
||||
it("treats an explicit null completed_at the same as an absent key", () => {
|
||||
expect(
|
||||
newestDurableTurnId([
|
||||
turn("turn-open", TIMESTAMP_11, undefined, { completed_at: null as unknown as string }),
|
||||
turn("turn-completed-later", TIMESTAMP_13, undefined, {
|
||||
completed_at: "2026-08-14T13:05:00Z",
|
||||
}),
|
||||
]),
|
||||
).toBe("turn-open");
|
||||
});
|
||||
});
|
||||
@@ -30,29 +30,58 @@ function durableTurnTimestampKey(value: string): string {
|
||||
return `${match[1]}.${(match[2] ?? "").padEnd(9, "0")}Z`;
|
||||
}
|
||||
|
||||
// A turn counts as lifecycle-only for exactly these four encodings, mirroring
|
||||
// the backend's turnMetadataFlagPredicate (D12): boolean true, number 1,
|
||||
// string "true", string "1". Everything else - an absent key, null,
|
||||
// undefined, false, 0, "", "false", "0", "yes", an object, an array - is
|
||||
// conversational. Deliberately a value-set membership check, not a
|
||||
// truthiness test (misreads "false"/"0"/"yes"/{}/[] as lifecycle) or a
|
||||
// String() coercion (misreads ["1"]).
|
||||
const LIFECYCLE_ONLY_VALUES: ReadonlySet<unknown> = new Set([true, 1, "true", "1"]);
|
||||
|
||||
function isLifecycleOnlyTurn(turn: Turn): boolean {
|
||||
return LIFECYCLE_ONLY_VALUES.has(turn.metadata?.lifecycle_only);
|
||||
}
|
||||
|
||||
// completed_at is optional on the wire, so an open turn arrives as an absent
|
||||
// key or explicit null; == null treats both alike.
|
||||
function isOpenTurn(turn: Turn): boolean {
|
||||
return turn.completed_at == null;
|
||||
}
|
||||
|
||||
// isNewerTurn mirrors D1's total order: an open turn always outranks a
|
||||
// completed one regardless of timestamps, then started_at, created_at, id -
|
||||
// all descending.
|
||||
function isNewerTurn(candidate: Turn, current: Turn): boolean {
|
||||
const candidateOpen = isOpenTurn(candidate);
|
||||
if (candidateOpen !== isOpenTurn(current)) return candidateOpen;
|
||||
const candidateKey = [
|
||||
durableTurnTimestampKey(candidate.started_at),
|
||||
durableTurnTimestampKey(candidate.created_at),
|
||||
// Mirrors the backend's final deterministic tie-break. Turn IDs do not
|
||||
// encode creation time; exact timestamp ties have no finer ordering.
|
||||
candidate.id,
|
||||
];
|
||||
const currentKey = [
|
||||
durableTurnTimestampKey(current.started_at),
|
||||
durableTurnTimestampKey(current.created_at),
|
||||
current.id,
|
||||
];
|
||||
for (let part = 0; part < candidateKey.length; part++) {
|
||||
if (candidateKey[part] === currentKey[part]) continue;
|
||||
return candidateKey[part] > currentKey[part];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function newestDurableTurnId(turns?: readonly Turn[]): string | null | undefined {
|
||||
if (turns === undefined) return undefined;
|
||||
if (turns.length === 0) return null;
|
||||
let newest = turns[0];
|
||||
for (let index = 1; index < turns.length; index++) {
|
||||
const candidate = turns[index];
|
||||
const newestKey = [
|
||||
durableTurnTimestampKey(newest.started_at),
|
||||
durableTurnTimestampKey(newest.created_at),
|
||||
newest.id,
|
||||
];
|
||||
const candidateKey = [
|
||||
durableTurnTimestampKey(candidate.started_at),
|
||||
durableTurnTimestampKey(candidate.created_at),
|
||||
// Mirrors the backend's final deterministic tie-break. Turn IDs do not
|
||||
// encode creation time; exact timestamp ties have no finer ordering.
|
||||
candidate.id,
|
||||
];
|
||||
for (let part = 0; part < candidateKey.length; part++) {
|
||||
if (candidateKey[part] === newestKey[part]) continue;
|
||||
if (candidateKey[part] > newestKey[part]) newest = candidate;
|
||||
break;
|
||||
}
|
||||
const eligible = turns.filter((candidate) => !isLifecycleOnlyTurn(candidate));
|
||||
if (eligible.length === 0) return null;
|
||||
let newest = eligible[0];
|
||||
for (let index = 1; index < eligible.length; index++) {
|
||||
const candidate = eligible[index];
|
||||
if (isNewerTurn(candidate, newest)) newest = candidate;
|
||||
}
|
||||
return newest.id;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-2026-08-14-current-turn-clarification-ownership: Current Turn Owns Active Clarification
|
||||
|
||||
**Status:** accepted (amended 2026-08-15)
|
||||
**Status:** accepted (amended 2026-08-15, 2026-08-24)
|
||||
**Date:** 2026-08-14
|
||||
**Area:** backend, frontend, protocol, workflow
|
||||
|
||||
@@ -27,8 +27,17 @@ and hides the question.
|
||||
The session's current turn owns active clarification state.
|
||||
|
||||
- A pending clarification is operational only when its message belongs to the session's newest durable
|
||||
`task_session_turns` record. Missing status retains the existing legacy meaning of pending only after
|
||||
current-turn ownership matches.
|
||||
**conversational** `task_session_turns` record — a turn whose `metadata.lifecycle_only` flag is not
|
||||
set. "Set" means exactly the value-set `{true, 1, "true", "1"}`; every other encoding — an absent
|
||||
key, `null`, `false`, `0`, `""`, `"false"`, `"0"`, any other string, an object, or an array — is
|
||||
conversational, matching `turnMetadataFlagPredicate`'s `IN ('true','1')` test on both dialects. That
|
||||
marker identifies a synthetic turn born already-completed by lifecycle bookkeeping (for example the
|
||||
agent-boot-on-resume record), not a turn representing genuine conversation; a lifecycle turn is
|
||||
excluded from current-turn resolution entirely, however recent. Among conversational turns,
|
||||
the newest is the one with no open successor: an open turn (`completed_at IS NULL`) always outranks
|
||||
a completed one regardless of timestamps, and only when both candidates are open or both are
|
||||
completed do `started_at`, then `created_at`, then `id` (all descending) break the tie. Missing
|
||||
status retains the existing legacy meaning of pending only after current-turn ownership matches.
|
||||
- Deleting messages never moves current-turn ownership backward or reactivates a superseded bundle.
|
||||
- Detachment preserves late-answer behavior only while that turn remains current. Once a newer turn is
|
||||
accepted, older pending rows become inert history without requiring a destructive rewrite.
|
||||
@@ -74,8 +83,8 @@ This ownership rule stands independently if ADR 0015 is later rejected, supersed
|
||||
session.
|
||||
- Pending projection performs bounded database reads on pending-sensitive events and normal list/boot
|
||||
hydration. It avoids unbounded transcript delivery or inactive-session subscriptions.
|
||||
- SQLite and PostgreSQL must select the same newest durable turn; dialect-sensitive repository tests
|
||||
are required.
|
||||
- SQLite and PostgreSQL must select the same newest durable conversational turn; dialect-sensitive
|
||||
repository tests are required.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 2026-08-12-empty-utility-bindings-inherit-default | [Empty Built-in Utility Bindings Inherit the Default](2026-08-12-empty-utility-bindings-inherit-default.md) | accepted | backend, frontend | 2026-08-12 |
|
||||
| 2026-08-12-setup-timeout-owns-launch-budget | [One Setup Timeout Owns Launch Budgets](2026-08-12-setup-timeout-owns-launch-budget.md) | accepted | backend | 2026-08-12 |
|
||||
| 2026-08-10-no-em-dash-public-copy | [Keep Em Dashes Out of Public Copy](2026-08-10-no-em-dash-public-copy.md) | accepted | frontend, infra | 2026-08-10 |
|
||||
| 2026-08-14-current-turn-clarification-ownership | [Current Turn Owns Active Clarification](2026-08-14-current-turn-clarification-ownership.md) | accepted (amended 2026-08-15) | backend, frontend, protocol, workflow | 2026-08-14 |
|
||||
| 2026-08-14-current-turn-clarification-ownership | [Current Turn Owns Active Clarification](2026-08-14-current-turn-clarification-ownership.md) | accepted (amended 2026-08-15, 2026-08-24) | backend, frontend, protocol, workflow | 2026-08-14 |
|
||||
| 2026-08-12-task-bound-fork-destinations | [Bind Fork Push Destinations to Tasks](2026-08-12-task-bound-fork-destinations.md) | accepted (amended 2026-08-13) | backend, frontend, workflow, security, GitHub | 2026-08-12 |
|
||||
| 2026-08-13-hard-delete-task-contribution-links | [Hard delete owns task contribution links](2026-08-13-hard-delete-task-contribution-links.md) | accepted | backend | 2026-08-13 |
|
||||
| 2026-08-15-office-mode-follows-active-workspace | [Office Mode Follows the Active Workspace](2026-08-15-office-mode-follows-active-workspace.md) | accepted | frontend, backend | 2026-08-15 |
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
# Current-turn authority: a lifecycle turn must never shadow the real turn
|
||||
|
||||
**Status:** spec
|
||||
**Area:** backend (task repository, task service, agent lifecycle), frontend (chat overlay), protocol
|
||||
**Slug:** `current-turn-authority`
|
||||
**Amends:** [ADR-2026-08-14 Current Turn Owns Active Clarification](../../decisions/2026-08-14-current-turn-clarification-ownership.md)
|
||||
|
||||
---
|
||||
|
||||
## Why
|
||||
|
||||
A pending `ask_user_question_kandev` clarification became invisible to every surface that exists to
|
||||
show it: `list_pending_questions_kandev` omitted it, the task-list badge cleared, the chat overlay
|
||||
stopped rendering it, and the Stall Session Watchdog could not tell the session from one with no
|
||||
open turn. Task `4be500f5` sat on an unanswered architectural question for 70+ minutes, found only
|
||||
because a human read the raw transcript. The cause is not a race and not data corruption, but a
|
||||
**stated invariant enforced in exactly one of eleven places**.
|
||||
|
||||
`Service.createCompletedTurn` (`apps/backend/internal/task/service/service_turns.go:274`) documents
|
||||
its own contract:
|
||||
|
||||
> persists a synthetic turn that is **never observable as active**. It is used for lifecycle
|
||||
> messages that must belong to a turn without making an idle task appear to have an active agent
|
||||
> turn.
|
||||
|
||||
That turn is created on every agent **resume** launch: `bootMsgAdapter.CreateMessage`
|
||||
(`apps/backend/internal/backendapp/worktree.go:57`) passes `CompletedTurn: isResuming` for the
|
||||
`agent_boot` `script_execution` message. It is a real `task_session_turns` row with
|
||||
`started_at = completed_at = created_at = now`, ordinary metadata, and one message.
|
||||
|
||||
Only `GetActiveTurnBySessionID` honours the invariant, by filtering `completed_at IS NULL`. Every
|
||||
other consumer resolves "the session's current turn" as
|
||||
|
||||
```sql
|
||||
ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC LIMIT 1
|
||||
```
|
||||
|
||||
gated by `turnAuthorityPredicate`. The synthetic turn passes that predicate on its **first**
|
||||
disjunct alone (`NOT (prompt_dispatch_pending)` is true; it never needs the message-EXISTS
|
||||
fallback), and its `started_at` is later. So it wins, and every clarification owned by the real,
|
||||
still-open turn is reclassified as superseded history.
|
||||
|
||||
### Evidence
|
||||
|
||||
In session `adddd3fc` the real turn `6d91a053` opened at 01:40:08.937 and the resuming boot script
|
||||
wrote a synthetic turn 0.8s later, which became "current". The `clarification_request` raised on
|
||||
`6d91a053` at 02:44 stayed invisible on every surface until 04:08, when that turn completed and the
|
||||
question became legitimately superseded history. It repeated minutes later: the ordinary resume path,
|
||||
not a one-off.
|
||||
|
||||
Across 6,133 production turns, 775 have `completed_at = started_at` — but **158 carry more than one
|
||||
message** (real turns buried by `AbandonTurn`) and 27 hold `clarification_request` rows, which is
|
||||
why D3 forbids inferring "synthetic" from that shape. Shadowing has occurred 207 times across 116
|
||||
sessions, leaving 7 pending clarifications across 5 sessions unreachable today.
|
||||
|
||||
---
|
||||
|
||||
## Prior art
|
||||
|
||||
### Our own prior reasoning
|
||||
|
||||
**The in-repo prior reasoning already answered the design fork.**
|
||||
[ADR-2026-08-14](../../decisions/2026-08-14-current-turn-clarification-ownership.md) (accepted,
|
||||
amended 2026-08-15) decided:
|
||||
|
||||
> A pending clarification is operational only when its message belongs to the session's **newest
|
||||
> durable `task_session_turns` record**.
|
||||
|
||||
It further requires that **one repository derivation** supply the clarification guard, detach/expiry
|
||||
fallback, response validation, per-session pending projection and task-summary reconciliation, and
|
||||
that both dialects select the same turn under dialect-sensitive tests (D9). It explicitly
|
||||
**rejected** deriving ownership from the latest surviving message, since deleting a newer turn's
|
||||
last message would roll the boundary backward and reactivate older pending history. This spec does
|
||||
not reopen that: turn identity stays the boundary.
|
||||
|
||||
**Where this spec departs.** "Newest durable record" assumes every durable turn represents
|
||||
conversational work. `createCompletedTurn` predates the ADR and creates durable records representing
|
||||
no work at all, whose own doc comment says they must never be treated as active. The two intents
|
||||
agree; only the word "durable" fails to separate them. This spec narrows it to "newest durable
|
||||
**conversational** record" (R1) and adds one ordering rule (R2) — which **requires amending the
|
||||
ADR** (AC-12), not a silent divergence.
|
||||
|
||||
Its "one repository derivation" requirement is also only **partly** honoured today: the ten call
|
||||
sites share `turnAuthorityPredicate` but hand-write their own ordering, and the frontend hand-writes
|
||||
a third copy. AC-3 and R3 close that.
|
||||
|
||||
Also read and honoured, both deferring to the same upstream rule rather than restating it:
|
||||
`external-question-answering/spec.md` (D4, D4a, L2a) and
|
||||
`tasks/system-design/clarification-active-lifecycle.md`, both under `docs/specs/`.
|
||||
|
||||
### What other products shipped
|
||||
|
||||
Queried the `saas-kb` corpus for agent-question surfacing and session-resume semantics: every hit
|
||||
(Warp, OpenHands) was end-user documentation that the feature exists, none documenting turn identity
|
||||
or resume semantics — the layer this defect lives in. Nothing there makes a *durable, queryable*
|
||||
"which question is still answerable" the contract, as Kandev already does.
|
||||
|
||||
---
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Turn record** — a row in `task_session_turns`.
|
||||
- **Lifecycle turn** — a turn created solely to give a lifecycle message a parent, by
|
||||
`Service.createCompletedTurn`. It represents no agent work and is born already completed.
|
||||
- **Conversational turn** — any turn record that is not a lifecycle turn.
|
||||
- **Current turn** — the single conversational turn owning a session's active state, as resolved by
|
||||
R1 + R2 below. May be absent.
|
||||
- **Authority-eligible** — a turn record that passes `turnAuthorityPredicate` (the existing
|
||||
reservation filter) **and** is conversational.
|
||||
- **Shadowing** — a turn that is not the intended current turn being resolved as the current turn.
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
**No schema change. No migration. No backfill.**
|
||||
|
||||
One new turn-metadata key, written at insert time:
|
||||
|
||||
| key | value | written by |
|
||||
| --- | --- | --- |
|
||||
| `lifecycle_only` | JSON `true` | `Service.createCompletedTurn`, on every turn it creates |
|
||||
|
||||
Declared as `models.TurnMetaKeyLifecycleOnly = "lifecycle_only"` beside the existing `TurnMetaKey*`
|
||||
constants in `internal/task/models/models.go`, and read with `turnMetadataFlagPredicate` so both
|
||||
dialects normalise it identically (D12), exactly as `prompt_dispatch_pending` already does.
|
||||
`models.Turn` already serialises `metadata` and the TypeScript `Turn` type already declares
|
||||
`metadata?: Record<string, unknown>`, so the marker reaches the frontend with no DTO change.
|
||||
|
||||
---
|
||||
|
||||
## Determinism and boundary rules
|
||||
|
||||
- **D1. The current-turn resolution is a total order over authority-eligible turn records**, keyed
|
||||
in this exact sequence, all named columns:
|
||||
1. `CASE WHEN completed_at IS NULL THEN 0 ELSE 1 END` **ascending** (open before completed)
|
||||
2. `started_at` **descending**
|
||||
3. `created_at` **descending**
|
||||
4. `id` **descending**
|
||||
|
||||
Key 4 is a primary key, so the order is total and no tie is broken by chance. Keys 2–4 are already
|
||||
in the code; key 1 is the only addition, and it is what stops a zero-duration completed turn
|
||||
outranking a genuinely open turn.
|
||||
|
||||
- **D2. Key 1 is safe only under the single-open-turn invariant**, namely: a session has at most one
|
||||
turn with `completed_at IS NULL`, and no open turn survives a session resume. `AbandonOpenTurns`
|
||||
already enforces the second half on the resume path, and the first half holds across all 6,133
|
||||
production turn records (zero sessions with more than one open turn). Key 1 changes
|
||||
the resolved turn **only** when the newest record is completed while an older record is still
|
||||
open — which under this invariant can only be the shadowing defect itself. It is neither assumed
|
||||
silently nor claimed as a guarantee: AC-8 requires the enforced half tested and the observed half
|
||||
to have a defined, tested resolution when violated.
|
||||
|
||||
- **D3. "Lifecycle" is a durable marker, never an inference from `completed_at = started_at`.**
|
||||
That equality is produced by two unrelated, both-legitimate paths: `createCompletedTurn` (a
|
||||
lifecycle turn) and `Repository.AbandonTurn` (a *real* turn buried after a crash, so its dead hours
|
||||
do not poison analytics). They are indistinguishable by shape: 158 zero-duration turns carry
|
||||
multi-message real conversations and 27 carry `clarification_request` rows, which any rule keyed on
|
||||
zero duration would silence permanently. A hard prohibition, not a preference.
|
||||
|
||||
- **D4. An absent `lifecycle_only` key means conversational.** Every turn record written before this
|
||||
change is therefore conversational, including the 775 existing zero-duration rows. Deliberate; see
|
||||
*Known residual*.
|
||||
|
||||
- **D5. A lifecycle turn never holds a `clarification_request`.** `createCompletedTurn` is reachable
|
||||
only from the boot-message path; clarification rows are written through `getOrStartTurn`. AC-7
|
||||
asserts this so R1's exclusion can never hide a question.
|
||||
|
||||
- **D6. Resolution is a pure function of committed rows**, taking no new lock and adding no write.
|
||||
Two concurrent readers of the same committed state resolve the same turn. A reader concurrent with
|
||||
an insert resolves against whichever snapshot its transaction sees; because lifecycle turns never
|
||||
compete for the current-turn slot, the resolved turn is the same whether or not a concurrent
|
||||
`createCompletedTurn` has committed. That independence is the point of the fix.
|
||||
|
||||
- **D7. Marking is idempotent by construction.** `createCompletedTurn` writes the marker in the same
|
||||
`INSERT` as the row. A retried boot-message creation produces another marked lifecycle turn; N
|
||||
marked lifecycle turns are as excluded as one, so retry needs no dedupe.
|
||||
|
||||
- **D8. Absent current turn is a legitimate result, not an error.** A session with zero
|
||||
authority-eligible turns resolves to no current turn. Every consumer already handles a `LIMIT 1`
|
||||
subquery returning no row (the `turn_id = (…)` join matches nothing), unchanged. A session whose
|
||||
only records are lifecycle turns now resolves to no current turn where it previously resolved to
|
||||
the lifecycle turn — correct, since it has had no conversational turn and holds no active state.
|
||||
|
||||
- **D9. SQLite and PostgreSQL must resolve identically**, including the `CASE WHEN completed_at IS
|
||||
NULL` key and the metadata-flag normalisation. Restated from the ADR because key 1 is new and
|
||||
untested in both dialects.
|
||||
|
||||
- **D10. "Current turn" and "active turn" are different questions and must not be merged.**
|
||||
`GetActiveTurnBySessionID` answers *"is a turn running right now"* and filters
|
||||
`completed_at IS NULL`; the other nine sites answer *"which turn owns this session's state"* and
|
||||
do not. Adopting AC-3's shared helper SHALL NOT remove that filter, or the function begins
|
||||
returning completed turns and `AbandonOpenTurns` burns its 16-iteration budget re-burying them.
|
||||
Because a lifecycle turn is always born completed, R1's exclusion is a **no-op** here, applied for
|
||||
uniformity, not effect.
|
||||
|
||||
- **D11. Both fragments must compose into the four SQL shapes already in the code**, changing
|
||||
nothing else at any site: the scalar subquery (`clarificationBundleQuery` plus the six in
|
||||
`message_clarification_response.go`), the `current_turn` CTE
|
||||
(`FindActiveClarificationMessagesBySessionID`), the `ROW_NUMBER() OVER (PARTITION BY
|
||||
task_session_id ORDER BY …)` window (`pendingActionsBySessionQuery`), and the top-level `ORDER BY
|
||||
… LIMIT 1` (`GetActiveTurnBySessionID`, which keeps its own `completed_at IS NULL` per D10).
|
||||
`predicate` joins each site's existing `WHERE`; `orderBy` replaces each site's ordering. Hence
|
||||
`orderBy` is a bare expression list, not a canned subquery, which would fit only the first shape.
|
||||
The window site resolves many sessions in one statement; a helper forcing it back to N scalar
|
||||
subqueries is a performance regression on the task list and SHALL NOT be accepted.
|
||||
Qualifying `orderBy` with `turnAlias` is safe at the window site, whose current text is
|
||||
unqualified, because the alias is in scope.
|
||||
|
||||
- **D12. A NULL or empty `metadata` column normalises to conversational.** The column is
|
||||
`TEXT DEFAULT '{}'` and `turnMetadataFlagPredicate` already wraps its extraction in
|
||||
`COALESCE(…, '')` before the `IN ('true','1')` test, so `NULL`, `''`, `'{}'` and malformed JSON
|
||||
all yield "not a lifecycle turn" — the same result D4 requires for an absent key, via the same
|
||||
existing helper. No new normalisation is introduced and none SHALL be added.
|
||||
|
||||
- **D13. Clock skew and future timestamps change nothing.** D1 keys 2 and 3 are compared as stored,
|
||||
unclamped: a turn whose `started_at` is in the future sorts first within its open/completed class,
|
||||
exactly as today, and key 4 keeps the order total regardless. This spec deliberately adds no clock
|
||||
validation — the defect is not a timestamp defect, and `started_at` ordering is already
|
||||
load-bearing in `turnHistoryPredicate`, the turns endpoint, and the frontend comparison.
|
||||
|
||||
- **D14. Bundle claim, winner/loser, and conflict semantics are unchanged.** This spec changes only
|
||||
*which turn* the claim predicate in `claimActiveClarificationBundle` compares against. Exactly one
|
||||
caller still claims a bundle, a loser still receives conflict with no write and no resume, and the
|
||||
restore-on-failed-delivery path is untouched. Those rules stay owned by
|
||||
`docs/specs/external-question-answering/spec.md` and
|
||||
[ADR-2026-08-14](../../decisions/2026-08-14-current-turn-clarification-ownership.md), named here
|
||||
so their absence below reads as deferral, not silence.
|
||||
|
||||
- **D15. The frontend rule is a deliberate subset, sound only via the history pre-filter.** The
|
||||
backend resolves `turnAuthorityPredicate AND NOT lifecycle`; `newestDurableTurnId` implements D1's
|
||||
ordering and D4's marker and **no part of** the authority predicate. That holds because every turn
|
||||
it sees arrives from the turns endpoint, already gated by `turnHistoryPredicate`. The predicates
|
||||
differ: authority also admits a dispatch-pending, dispatch-attempted, message-less turn, which
|
||||
history excludes, so such a turn can be current on the backend and never reach the frontend. That
|
||||
gap is accepted, not accidental — such a turn has no message and so no clarification to hide — and
|
||||
it is why AC-10's fixture is confined to authority-eligible turns: a fixture fed straight to both
|
||||
implementations bypasses the pre-filter the frontend relies on, and would assert an agreement
|
||||
neither implementation claims.
|
||||
|
||||
---
|
||||
|
||||
## What
|
||||
|
||||
Each criterion is observable through the database, HTTP API, MCP tool surface, or web UI.
|
||||
|
||||
### R1 — a lifecycle turn is never current-turn authority
|
||||
|
||||
- **AC-1.** When `Service.createCompletedTurn` persists a turn, the system SHALL write
|
||||
`metadata.lifecycle_only = true` in the same insert as the turn row.
|
||||
|
||||
- **AC-2.** The system SHALL exclude every turn whose `lifecycle_only` flag normalises to true from
|
||||
current-turn resolution, at **every** site resolving a session's current turn. Those sites are
|
||||
exactly:
|
||||
|
||||
| file | site |
|
||||
| --- | --- |
|
||||
| `task/repository/sqlite/session.go` | `GetActiveTurnBySessionID` |
|
||||
| `task/repository/sqlite/message.go` | `FindActiveClarificationMessagesBySessionID`; `pendingActionsBySessionQuery` |
|
||||
| `task/repository/sqlite/clarification_bundle_query.go` | `clarificationBundleQuery` |
|
||||
| `task/repository/sqlite/message_clarification_response.go` | `DetachActiveClarificationMessagesBySessionID`; `ExpireActiveClarificationBundle`; `loadRestorableClarificationBundle`; `restoreClarificationMessages`; `claimActiveClarificationBundle`; `loadClaimedClarificationBundle` |
|
||||
|
||||
A site added later that resolves a current turn without the shared builder is a defect against
|
||||
AC-3, not a gap in this list.
|
||||
|
||||
- **AC-3.** The system SHALL express D1's ordering and R1's exclusion in **one** named Go helper in
|
||||
`internal/task/repository/sqlite/turn_authority.go`:
|
||||
|
||||
```go
|
||||
func currentTurnAuthority(driverName, turnAlias string) (predicate, orderBy string)
|
||||
```
|
||||
|
||||
Two fragments: the rule occupies two SQL positions and no one string sits in both.
|
||||
`predicate` is `turnAuthorityPredicate` AND R1's exclusion (`NOT` the `lifecycle_only` flag, via
|
||||
`turnMetadataFlagPredicate`) as one parenthesised conjunct, for `WHERE … AND %s`. `orderBy` is
|
||||
D1's four keys as a bare expression list qualified by `turnAlias`, with no `ORDER BY` keyword and
|
||||
no `LIMIT`, so it drops unchanged into both `ORDER BY %s` and `ROW_NUMBER() OVER (… ORDER BY %s)`.
|
||||
Every AC-2 site SHALL take both from this one call and hand-write neither.
|
||||
`turnAuthorityPredicate` is today called at exactly those ten sites, so subsuming it affects no
|
||||
other consumer. This is the concrete form of the ADR's "one repository derivation" requirement.
|
||||
|
||||
- **AC-4.** While a session's real turn is open and a lifecycle turn exists with a later
|
||||
`started_at`, the system SHALL resolve the current turn to the real turn, asserted by **a Go
|
||||
integration test over the service and repository layers**; all three consumers below are Go-
|
||||
reachable, so no browser-level test is required. Reproducing the sequence in *Why*: after the
|
||||
lifecycle turn is written, `list_pending_questions_kandev` SHALL return the bundle (through the
|
||||
MCP handler in `internal/mcp/server/question_handlers.go`), the session's `pending_action` SHALL
|
||||
be `clarification` (through `GetPendingActionsBySessionIDs`), and `POST
|
||||
/api/v1/clarification/:pendingId/respond` SHALL succeed rather than return conflict (through
|
||||
`internal/clarification`).
|
||||
|
||||
The lifecycle turn SHALL come from the real write path — `Service.CreateMessage` with
|
||||
`CompletedTurn: true` — never an inserted row imitating it, so the test fails if AC-1's marker is
|
||||
dropped. Ordering SHALL be made deterministic by seeding the conversational turn with an explicit
|
||||
**past** `started_at` (the existing `seedBundleTurnAt` idiom), since `createCompletedTurn`
|
||||
stamps `time.Now().UTC()` and is therefore strictly later on every run. Starting both turns at
|
||||
"now" and relying on them to differ SHALL NOT be accepted: on a coarse clock they tie on
|
||||
`started_at` and the assertion turns on `id`, which is unrelated to the behaviour tested.
|
||||
|
||||
- **AC-7.** On the boot-on-resume path, every `clarification_request` written by the open
|
||||
conversational turn SHALL keep **that** turn's `turn_id`, asserted as a repository-level test over
|
||||
a fixture exercising the path. The broader claim — that no `clarification_request` anywhere can
|
||||
carry a lifecycle `turn_id` — holds only because `CreateMessageRequest.CompletedTurn` is tagged
|
||||
`json:"-"` (no API client can set it) and has one production caller, which writes a
|
||||
`script_execution` message. Neither property is type-enforced, so AC-7 SHALL also assert the
|
||||
first: unmarshalling a body that sets `completed_turn` SHALL leave the field false. A caller later
|
||||
setting it while writing a `clarification_request` voids the claim and is a defect here.
|
||||
|
||||
### R2 — an open turn outranks a completed one
|
||||
|
||||
- **AC-5.** The system SHALL order current-turn candidates by D1's four keys in that order, so a
|
||||
turn with `completed_at IS NULL` is preferred over any completed turn regardless of `started_at`,
|
||||
and ties within each class fall through to `started_at`, `created_at`, `id` descending.
|
||||
|
||||
- **AC-6.** If a session has an open conversational turn and an unmarked legacy zero-duration turn
|
||||
with a later `started_at`, then the system SHALL resolve the current turn to the open turn. This
|
||||
rescues the pre-existing 775 unmarked rows in the only case where invisibility is actively
|
||||
harmful; see *Known residual* for the case it does not rescue.
|
||||
|
||||
- **AC-8.** The system SHALL assert the two halves of D2 separately, because only one is a
|
||||
guarantee. That `AbandonOpenTurns` leaves no turn with `completed_at IS NULL` is enforced and
|
||||
SHALL be tested directly. "At most one open turn per session" is **observed**, not enforced — no
|
||||
unique or partial index constrains it — so the system SHALL instead assert the defined behaviour
|
||||
when it does not hold: given two open turns on one session, resolution SHALL pick the one winning
|
||||
D1 keys 2 through 4, deterministically and without error. A test asserting the invariant itself
|
||||
could only check the fixture it just built, and would pass while production drifted.
|
||||
|
||||
### R3 — the frontend consumes the same rule
|
||||
|
||||
- **AC-9.** `newestDurableTurnId` in `apps/web/lib/utils/pending-clarification.ts` SHALL apply the
|
||||
same two rules — skip lifecycle turns, prefer a turn with no `completed_at` — before its existing
|
||||
`started_at` / `created_at` / `id` comparison, reading the marker from the turn payload rather
|
||||
than inferring anything from timestamps.
|
||||
|
||||
It SHALL treat a turn as lifecycle **only** when `metadata.lifecycle_only` is one of exactly four
|
||||
values: boolean `true`, number `1`, string `"true"`, string `"1"`. Everything else — absent key,
|
||||
`null`, `undefined`, `false`, `0`, `""`, `"false"`, `"0"`, `"yes"`, any object or array — is
|
||||
conversational. A bare truthiness test SHALL NOT be used, nor a `String(value)` coercion:
|
||||
truthiness misreads `"false"`, `"0"`, `"yes"`, `{}` and `[]` as lifecycle, coercion misreads
|
||||
`["1"]`, and D12's backend predicate (`… IN ('true','1')`) treats none of them as lifecycle. A
|
||||
divergence hides a clarification the backend is serving — the symptom this spec exists to
|
||||
remove. AC-10's fixture SHALL pin these values.
|
||||
|
||||
Its open-turn check SHALL be `turn.completed_at == null`, treating `null` and `undefined` alike:
|
||||
the payload type declares `completed_at?: string`, so an open turn arrives as an absent key while
|
||||
AC-10's fixture writes `null`. When every turn is a lifecycle turn it SHALL return `null`, not
|
||||
`undefined` — `null` means history loaded with no durable turn and hides all messages, whereas
|
||||
`undefined` means history not loaded and falls back to `pendingAction`, reopening the shadowing
|
||||
this spec removes.
|
||||
|
||||
- **AC-10.** The Go and TypeScript resolutions SHALL be validated against **one shared fixture
|
||||
file**, consumed by both a Go repository test and a TypeScript unit test. Two implementations of
|
||||
one rule are tolerable only while a single artifact proves they agree, so it is specified here.
|
||||
|
||||
Path: `apps/backend/internal/task/repository/sqlite/testdata/current_turn_resolution.json`, under
|
||||
the Go package because `go:embed` cannot reach outside its tree while the TypeScript test can
|
||||
reach it by relative path. Format: a JSON object with a `cases` array, each case
|
||||
`{ name, turns, expected_current_turn_id }` and each turn
|
||||
`{ id, started_at, created_at, completed_at, metadata }`, timestamps RFC 3339 UTC and
|
||||
`completed_at: null` when open. `expected_current_turn_id: null` means **no** current turn
|
||||
resolves; the fixture SHALL carry at least one such case (D8, Scenario 5).
|
||||
|
||||
Go SHALL read it with `os.ReadFile("testdata/current_turn_resolution.json")` — under `go test` the
|
||||
working directory is the package directory — then seed each case and run the real query.
|
||||
TypeScript SHALL resolve the path from the test file via `import.meta.url`, **not** the process
|
||||
working directory, which under Vitest is `apps/web`. If the file is missing, unreadable or
|
||||
unparseable both tests SHALL fail and neither may skip, because a fixture that silently stops
|
||||
being read is indistinguishable from two implementations that agree. Both SHALL run every
|
||||
case; either may add its own but SHALL NOT filter the shared ones. The cases SHALL cover every
|
||||
value in AC-9's list: the four resolving as lifecycle, and ten conversational — AC-9 names eleven,
|
||||
but an absent key and `undefined` are indistinguishable in JSON and SHALL be written once, as the
|
||||
absent key.
|
||||
|
||||
The fixture describes turns of **one** session and carries no `task_session_id` or `task_id`; both
|
||||
are `NOT NULL` under a foreign key, so the Go test SHALL supply its own session and task and stamp
|
||||
every turn with them. No fixture turn SHALL set a `prompt_dispatch_*` key, so every fixture turn is
|
||||
authority-eligible by construction and the fixture exercises D1's ordering and D4's marker only —
|
||||
the scope D15 requires. The TypeScript loader SHALL map `completed_at: null` to an absent property
|
||||
on each `Turn` and `expected_current_turn_id: null` to `null`; the production payload type is not
|
||||
widened to admit `null`.
|
||||
|
||||
- **AC-11.** `GET /api/v1/task-sessions/:sessionId/turns` SHALL include `metadata.lifecycle_only` on
|
||||
a lifecycle turn in its response payload, so AC-9 has the input it needs. `turnHistoryPredicate`
|
||||
SHALL keep including lifecycle turns in visible history — the boot message stays grouped under its
|
||||
own turn in the transcript, unchanged.
|
||||
|
||||
### R4 — the decision record
|
||||
|
||||
- **AC-12.** The system SHALL amend
|
||||
`docs/decisions/2026-08-14-current-turn-clarification-ownership.md` so its stated rule reads
|
||||
"newest durable **conversational** turn record", names the `lifecycle_only` marker, and records
|
||||
D1's ordering including the open-turn key. It SHALL also correct that ADR's *Consequences*
|
||||
section, which restates the rule as "SQLite and PostgreSQL must select the same newest durable
|
||||
turn". The ADR is the only place the rule is written down as a decision, and leaving it saying
|
||||
"newest durable record" would reintroduce this defect the next time someone implements against it.
|
||||
|
||||
- **AC-13.** The system SHALL update
|
||||
`docs/specs/tasks/system-design/clarification-active-lifecycle.md`, whose *Data model* section
|
||||
states "The newest authoritative durable `task_session_turns` record for the session identifies the
|
||||
current turn", whose *API surface* section orders visible history "matching the reverse ordering
|
||||
used to select the current turn", and whose *Persistence guarantees* section calls clarification
|
||||
state reconstructable from "the newest authoritative durable turn" — all three inaccurate under D1.
|
||||
|
||||
---
|
||||
|
||||
## Failure modes
|
||||
|
||||
- **A lifecycle turn is written without the marker** (a future second producer): treated as
|
||||
conversational, can shadow again. AC-1 writes the marker inside `createCompletedTurn` rather than
|
||||
at its call sites, so every caller inherits it.
|
||||
- **A new consumer hand-writes its own current-turn subquery**: silent divergence. AC-3's shared
|
||||
helper is the only thing that makes it visible in review.
|
||||
- **Two open turns coexist**: D1 keys 2 to 4 choose between them, the same answer today's rule
|
||||
gives. AC-8 requires that resolution tested, not assumed away.
|
||||
- **An unmarked legacy lifecycle turn is newest and the real turn is also completed**: it stays
|
||||
current and the older question stays invisible. Not fixed; see *Known residual*.
|
||||
- **`metadata.lifecycle_only` present but outside `{true, 1, "true", "1"}`**: normalised to "not a
|
||||
lifecycle turn" by `turnMetadataFlagPredicate` (D12) and AC-9's enumeration. No new rule.
|
||||
|
||||
---
|
||||
|
||||
## Known residual
|
||||
|
||||
**The 775 existing turn records carry no `lifecycle_only` marker and this spec adds no backfill.**
|
||||
D3 explains why no migration can safely identify them: their only shared observable shape,
|
||||
`completed_at = started_at`, is also the shape of 158 real conversations and 27 turns holding
|
||||
`clarification_request` rows a backfill would silence permanently — converting a bounded visibility
|
||||
bug into unbounded data loss. Consequently:
|
||||
|
||||
- A legacy lifecycle turn **can no longer shadow an open turn** — R2/AC-6 covers that, the case
|
||||
where a live question is hidden from a human who could still answer it.
|
||||
- A legacy lifecycle turn **still shadows a completed turn**. Concretely, 4 of the 7 currently
|
||||
unreachable pending clarifications (2026-08-10 to 2026-08-18) keep a legacy lifecycle turn as their
|
||||
resolved current turn while their own turn is closed. Under
|
||||
[ADR-2026-08-14](../../decisions/2026-08-14-current-turn-clarification-ownership.md) a question
|
||||
whose turn closed with no successor is superseded history anyway, so their observable outcome
|
||||
matches the intended contract even though the path to it is wrong. Named so their persistence is
|
||||
not mistaken for an incomplete fix.
|
||||
- The residual may shrink as eligible sessions expire; AC-1 prevents any new unmarked lifecycle turn.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
Named exclusions are contracts. Silence would be a defect.
|
||||
|
||||
- **The Stall Session Watchdog automation** (`065b95a3-f69f-43dc-96e9-6fedbf6df021`). Its STEP 3
|
||||
hand-writes `ORDER BY started_at DESC LIMIT 1` with no authority predicate and no tiebreakers,
|
||||
then tests `completed_at IS NULL`, so a lifecycle turn makes a genuinely stalled session look like
|
||||
it has no open turn and the watchdog never nudges it. **A real second symptom of the same root
|
||||
cause**, but the prompt lives in the `automations` table, not version control, so its owner
|
||||
must update it — to D1's ordering, or at minimum "newest turn that is not `lifecycle_only`".
|
||||
- **Task `35d108bb`'s turn-completion duplicate race.** That concerns
|
||||
`acquireTurnCompletionCriticalSection` double-dispatching workflow `on_enter` actions: duplicate
|
||||
**dispatches**, not duplicate turn rows. The extra row here is created deliberately, by name, on a
|
||||
documented path. **Unrelated**; `35d108bb` landing would change nothing here.
|
||||
- **Backfilling or deleting the 775 existing unmarked lifecycle turns.** Prohibited by D3,
|
||||
quantified in *Known residual*.
|
||||
- **Changing what `AbandonTurn` writes.** `completed_at = started_at` on a buried orphan is
|
||||
deliberate analytics behaviour, untouched.
|
||||
- **Removing the lifecycle turn concept**, e.g. a nullable `turn_id` on the boot message or
|
||||
attaching it to the real turn. A larger change to the message/turn foreign key and to transcript
|
||||
grouping, not needed to close this defect.
|
||||
- **Replacing the frontend's independent resolution with a server-supplied `current_turn_id` field.**
|
||||
A cleaner end state, but it changes the turns endpoint envelope. AC-9 + AC-10 close the drift risk
|
||||
at a fraction of the blast radius. A follow-up card, not this one.
|
||||
- **The `turnHistoryPredicate` visible-history rule.** Unchanged by AC-11.
|
||||
- **Anything about permissions, authorization, or the visibility predicate** in
|
||||
`clarificationBundleQuery`. Untouched.
|
||||
|
||||
---
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. **Resume during an open turn, question asked afterwards.** Real turn opens; a marked lifecycle
|
||||
turn is written 0.8s later; the agent asks on the real turn. The question stays listed, badged
|
||||
and answerable throughout. (AC-1, AC-2, AC-4)
|
||||
2. **Resume between turns.** Previous turn completes, a marked lifecycle turn is written, a new real
|
||||
turn opens 3s later; current turn is the new real turn, so the ordinary path is unchanged. (AC-5)
|
||||
3. **Legacy unmarked lifecycle turn over an open turn.** No marker, `completed_at = started_at`,
|
||||
later than an open conversational turn; current turn is the open turn. (AC-6)
|
||||
4. **Legacy unmarked lifecycle turn over a completed turn.** Same fixture, real turn closed; the
|
||||
legacy turn stays current and the older bundle stays superseded, asserting the residual so it
|
||||
cannot change silently. (*Known residual*)
|
||||
5. **Session with only lifecycle turns.** No current turn resolves, nothing is listed, no consumer
|
||||
errors. (D8)
|
||||
6. **Both dialects.** Scenarios 1-5 give identical resolved turn ids on SQLite and PostgreSQL. (D9)
|
||||
7. **Frontend agreement.** AC-10's fixture yields the same resolved turn id from the Go helper and
|
||||
from `newestDurableTurnId`. (AC-9, AC-10)
|
||||
|
||||
---
|
||||
|
||||
## Verification notes
|
||||
|
||||
- The repository-level tests belong beside `clarification_bundle_query_test.go`, whose header
|
||||
comment documents the old `(started_at, created_at, id)` ordering and must be updated for key 1.
|
||||
- AC-4's integration test proves the backend-owned surfaces recover (MCP listing, `pending_action`,
|
||||
the respond endpoint). The chat overlay is frontend-owned, proven by AC-9 and AC-10, not AC-4.
|
||||
- `apps/backend/internal/orchestrator/clarification_guard.go` is the most severe consumer: a shadowed
|
||||
clarification makes `sessionHasPendingClarification` return false, letting a workflow step
|
||||
auto-advance past an unanswered question. Worth an explicit test.
|
||||
|
||||
### User-visible surfaces touched
|
||||
|
||||
The chat clarification overlay (`pending-clarification.ts`); the task-list pending badge via
|
||||
`pending_action`; `POST /api/v1/clarification/:pendingId/respond` success vs. conflict; MCP
|
||||
`list_pending_questions_kandev` and `answer_question_kandev`; workflow step auto-advance via the
|
||||
clarification guard. No new UI copy, so no i18n catalogue work.
|
||||
@@ -34,8 +34,12 @@ No schema change.
|
||||
- Clarification questions remain `task_session_messages` rows with
|
||||
`type = "clarification_request"`.
|
||||
- Rows in one bundle share `metadata.pending_id`; terminal status remains in `metadata.status`.
|
||||
- `task_session_messages.turn_id` associates a question with its turn. The newest authoritative durable
|
||||
`task_session_turns` record for the session identifies the current turn; deleting messages does not
|
||||
- `task_session_messages.turn_id` associates a question with its turn. The newest durable
|
||||
**conversational** `task_session_turns` record for the session identifies the current turn — a
|
||||
turn whose `metadata.lifecycle_only` flag is not set; a lifecycle turn (for example the
|
||||
agent-boot-on-resume record) is never current-turn authority, however recent. Ranking among
|
||||
conversational turns is: an open turn (`completed_at IS NULL`) before any completed turn regardless
|
||||
of timestamps, then `started_at`, `created_at`, and `id`, all descending. Deleting messages does not
|
||||
delete that parent turn or move ownership backward.
|
||||
- A pre-acknowledgement successor carries `metadata.prompt_dispatch_pending=true` plus the source
|
||||
clarification turn, pending ID, and exact claimed message IDs. An empty row carrying only the pending
|
||||
@@ -66,11 +70,15 @@ No new route. Task-session responses add `pending_action_revision` beside the ex
|
||||
decimal epoch is a database-backed, monotonically allocated backend generation; sequence orders
|
||||
reads within that generation. Clients compare both fields and reject any older result, including
|
||||
an unseen pre-restart epoch delivered after client state has been rebuilt.
|
||||
- `GET /api/v1/task-sessions/:sessionId/turns` continues to expose durable turn history;
|
||||
unpublished reservations stay hidden until publication or durable message evidence, including while
|
||||
an attempt marker makes them internal current-turn authority. Attempted reservations are preserved
|
||||
across restart because their dispatch outcome is ambiguous. Visible history is ordered ascending by
|
||||
`started_at`, `created_at`, then `id`, matching the reverse ordering used to select the current turn.
|
||||
- `GET /api/v1/task-sessions/:sessionId/turns` continues to expose durable turn history, including
|
||||
lifecycle turns and their `metadata.lifecycle_only` marker; unpublished reservations stay hidden
|
||||
until publication or durable message evidence, including while an attempt marker makes them internal
|
||||
current-turn authority. Attempted reservations are preserved across restart because their dispatch
|
||||
outcome is ambiguous. Visible history is ordered ascending by `started_at`, `created_at`, then `id`
|
||||
across every visible turn, lifecycle or conversational. That is the reverse of the tie-break keys
|
||||
used to select the current turn, but not the full current-turn ordering: current-turn selection also
|
||||
excludes lifecycle turns entirely and ranks an open turn ahead of any completed one regardless of
|
||||
timestamp, neither of which visible history does.
|
||||
- Task list, workflow snapshot, and boot payloads continue to expose task-level `pending_action` in
|
||||
the status summary and legacy fallback fields.
|
||||
- `POST /api/v1/clarification/:pendingId/respond` uses one state-based contract:
|
||||
@@ -240,7 +248,7 @@ session they can already access. Session selection does not broaden task visibil
|
||||
- Message history remains durable and is not destructively rewritten merely because a newer turn
|
||||
exists.
|
||||
- Active clarification state is reconstructable after restart from message status plus the newest
|
||||
authoritative durable turn.
|
||||
durable conversational turn (a lifecycle turn is never authoritative, however recent).
|
||||
- Current-turn ownership is reconstructable from durable turn rows even when a turn has no remaining
|
||||
messages.
|
||||
- Unpublished detached-answer reservations carry enough recovery identity to restore only their own
|
||||
|
||||
Reference in New Issue
Block a user