2dc1596667
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
232 lines
9.7 KiB
Go
232 lines
9.7 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
"github.com/kandev/kandev/internal/agentctl/tracing"
|
|
"github.com/kandev/kandev/internal/db/dialect"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
)
|
|
|
|
// pendingInteractionColumns is the message projection both pending-interaction
|
|
// CTEs select. Kept identical to scanMessageRows' expected column order so the
|
|
// UNION ALL below stays scannable by the shared helper.
|
|
const pendingInteractionColumns = `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`
|
|
|
|
// ListPendingInteractions returns the durable request rows for every agent
|
|
// interaction still owed a user response, using the SAME authority the compact
|
|
// pending-action projection uses (GetPendingActionsBySessionIDs): only the
|
|
// session's current durable turn counts, terminal sessions quarantine pending
|
|
// history, and a permission row only counts when it is the newest permission of
|
|
// that turn. Anything weaker re-reports interactions kandev's own list surfaces
|
|
// already consider settled — the false-positive class ADR 0052 exists to close.
|
|
//
|
|
// Clarification bundles come back as one row per question (all sharing
|
|
// metadata.pending_id); the service layer groups them into a single
|
|
// interaction. A bundle qualifies when ANY of its questions is still pending,
|
|
// and then EVERY question of that bundle is returned: the agent stays blocked
|
|
// until the whole bundle is answered, so a caller handed only a partially
|
|
// answered bundle's pending rows would build a response the answer path
|
|
// rejects for having too few answers. It also keeps this read and
|
|
// GetInteraction (which resolves by pending_id) agreeing on a question set.
|
|
// Rows are ordered oldest-first by (created_at, id).
|
|
func (r *Repository) ListPendingInteractions(
|
|
ctx context.Context,
|
|
filter models.PendingInteractionFilter,
|
|
) ([]*models.Message, error) {
|
|
ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListPendingInteractions")
|
|
defer span.End()
|
|
|
|
query, args := buildPendingInteractionQuery(r.ro.DriverName(), filter)
|
|
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
messages, _, err := scanMessageRows(rows, 0)
|
|
return messages, err
|
|
}
|
|
|
|
// buildPendingInteractionQuery assembles the pending-interaction query. The
|
|
// session-scoping subquery is shared by the current-turn CTE and both message
|
|
// CTEs, so a session/task filter narrows the authority window itself rather
|
|
// than filtering rows the CTEs already resolved against a wider set.
|
|
func buildPendingInteractionQuery(
|
|
driverName string,
|
|
filter models.PendingInteractionFilter,
|
|
) (string, []interface{}) {
|
|
statusExpr := dialect.JSONExtract(driverName, "m.metadata", "status")
|
|
pendingIDExpr := dialect.JSONExtract(driverName, "m.metadata", "pending_id")
|
|
scopedSessions, args := pendingInteractionSessionScope(filter)
|
|
kindConditions, kindArgs := pendingInteractionKindClauses(filter.Kinds)
|
|
predicate, orderBy := currentTurnAuthority(driverName, "turn_row")
|
|
|
|
query := renderPendingInteractionQuery(pendingInteractionQueryParts{
|
|
scopedSessions: scopedSessions,
|
|
currentTurnOrder: orderBy,
|
|
currentTurnPredicate: predicate,
|
|
nonTerminalSessionPredicate: nonTerminalSessionPredicate("turn_row"),
|
|
pendingIDExpr: pendingIDExpr,
|
|
clarificationMessageType: string(models.MessageTypeClarificationRequest),
|
|
statusExpr: statusExpr,
|
|
pendingStatus: string(models.InteractionStatusPending),
|
|
pendingInteractionColumns: pendingInteractionColumns,
|
|
permissionOrder: pendingActionMessageOrder(driverName, "m"),
|
|
permissionMessageType: string(models.MessageTypePermissionRequest),
|
|
})
|
|
if kindConditions != "" {
|
|
query = "SELECT * FROM (" + query + ") interactions WHERE " + kindConditions
|
|
args = append(args, kindArgs...)
|
|
}
|
|
query += " ORDER BY created_at ASC, id ASC"
|
|
return query, args
|
|
}
|
|
|
|
// pendingInteractionQueryParts names every dynamic SQL fragment in the
|
|
// pending-interaction template. Named replacement keeps a template edit from
|
|
// silently shifting a long positional fmt.Sprintf argument list.
|
|
type pendingInteractionQueryParts struct {
|
|
scopedSessions string
|
|
currentTurnOrder string
|
|
currentTurnPredicate string
|
|
nonTerminalSessionPredicate string
|
|
pendingIDExpr string
|
|
clarificationMessageType string
|
|
statusExpr string
|
|
pendingStatus string
|
|
pendingInteractionColumns string
|
|
permissionOrder string
|
|
permissionMessageType string
|
|
}
|
|
|
|
func renderPendingInteractionQuery(parts pendingInteractionQueryParts) string {
|
|
return strings.NewReplacer(
|
|
"{{scoped_sessions}}", parts.scopedSessions,
|
|
"{{current_turn_order}}", parts.currentTurnOrder,
|
|
"{{current_turn_predicate}}", parts.currentTurnPredicate,
|
|
"{{non_terminal_session_predicate}}", parts.nonTerminalSessionPredicate,
|
|
"{{pending_id_expr}}", parts.pendingIDExpr,
|
|
"{{clarification_message_type}}", parts.clarificationMessageType,
|
|
"{{status_expr}}", parts.statusExpr,
|
|
"{{pending_status}}", parts.pendingStatus,
|
|
"{{pending_interaction_columns}}", parts.pendingInteractionColumns,
|
|
"{{permission_order}}", parts.permissionOrder,
|
|
"{{permission_message_type}}", parts.permissionMessageType,
|
|
).Replace(pendingInteractionQueryTemplate)
|
|
}
|
|
|
|
// pendingInteractionQueryTemplate is the named template behind
|
|
// buildPendingInteractionQuery. current_turn resolves its turn through
|
|
// currentTurnAuthority, the same predicate and ordering every other
|
|
// current-turn resolution site in this package uses, so this query and the
|
|
// compact pending-action projection (GetPendingActionsBySessionIDs) always
|
|
// agree on which turn is current. The pinning test
|
|
// (TestListPendingInteractionsAgreesWithPendingActionProjection) fails if the
|
|
// two ever disagree on a session.
|
|
const pendingInteractionQueryTemplate = `
|
|
WITH scoped_sessions AS ({{scoped_sessions}}),
|
|
current_turn AS (
|
|
SELECT task_session_id, turn_id
|
|
FROM (
|
|
SELECT task_session_id,
|
|
id AS turn_id,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY task_session_id
|
|
ORDER BY {{current_turn_order}}
|
|
) AS rn
|
|
FROM task_session_turns turn_row
|
|
WHERE turn_row.task_session_id IN (SELECT id FROM scoped_sessions)
|
|
AND {{current_turn_predicate}}
|
|
AND {{non_terminal_session_predicate}}
|
|
) ranked
|
|
WHERE rn = 1
|
|
),
|
|
pending_bundles AS (
|
|
SELECT DISTINCT m.task_session_id, {{pending_id_expr}} AS pending_id
|
|
FROM task_session_messages m
|
|
JOIN current_turn current
|
|
ON current.task_session_id = m.task_session_id
|
|
AND current.turn_id = m.turn_id
|
|
WHERE m.type = '{{clarification_message_type}}'
|
|
AND COALESCE({{status_expr}}, '') IN ('', '{{pending_status}}')
|
|
),
|
|
pending_clarifications AS (
|
|
SELECT {{pending_interaction_columns}}
|
|
FROM task_session_messages m
|
|
JOIN current_turn current
|
|
ON current.task_session_id = m.task_session_id
|
|
AND current.turn_id = m.turn_id
|
|
JOIN pending_bundles bundle
|
|
ON bundle.task_session_id = m.task_session_id
|
|
AND bundle.pending_id = {{pending_id_expr}}
|
|
WHERE m.type = '{{clarification_message_type}}'
|
|
),
|
|
ranked_permissions AS (
|
|
SELECT {{pending_interaction_columns}},
|
|
COALESCE({{status_expr}}, '') AS permission_status,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY m.task_session_id
|
|
ORDER BY m.created_at DESC, {{permission_order}} DESC
|
|
) AS rn
|
|
FROM task_session_messages m
|
|
JOIN current_turn current
|
|
ON current.task_session_id = m.task_session_id
|
|
AND current.turn_id = m.turn_id
|
|
WHERE m.type = '{{permission_message_type}}'
|
|
)
|
|
SELECT id, task_session_id, task_id, turn_id, author_type, author_id,
|
|
content, requests_input, type, metadata, created_at, updated_at
|
|
FROM pending_clarifications
|
|
UNION ALL
|
|
SELECT id, task_session_id, task_id, turn_id, author_type, author_id,
|
|
content, requests_input, type, metadata, created_at, updated_at
|
|
FROM ranked_permissions
|
|
WHERE rn = 1 AND permission_status IN ('', '{{pending_status}}')
|
|
`
|
|
|
|
// pendingInteractionSessionScope builds the scoped_sessions subquery. Session
|
|
// and task filters are ANDed (a session must match both axes when both are
|
|
// set), matching PluginMessageFilter's documented semantics.
|
|
func pendingInteractionSessionScope(filter models.PendingInteractionFilter) (string, []interface{}) {
|
|
query := "SELECT id FROM task_sessions"
|
|
var conditions []string
|
|
var args []interface{}
|
|
if ph, inArgs := buildInPlaceholders(filter.SessionIDs); ph != "" {
|
|
conditions = append(conditions, "id IN ("+ph+")")
|
|
args = append(args, inArgs...)
|
|
}
|
|
if ph, inArgs := buildInPlaceholders(filter.TaskIDs); ph != "" {
|
|
conditions = append(conditions, "task_id IN ("+ph+")")
|
|
args = append(args, inArgs...)
|
|
}
|
|
if len(conditions) > 0 {
|
|
query += " WHERE " + strings.Join(conditions, " AND ")
|
|
}
|
|
return query, args
|
|
}
|
|
|
|
// pendingInteractionKindClauses narrows the UNION result to the requested
|
|
// interaction kinds by mapping each kind back to its message type. An unknown
|
|
// kind matches nothing rather than everything, so a typo fails closed.
|
|
func pendingInteractionKindClauses(kinds []string) (string, []interface{}) {
|
|
if len(kinds) == 0 {
|
|
return "", nil
|
|
}
|
|
types := make([]string, 0, len(kinds))
|
|
for _, kind := range kinds {
|
|
switch models.InteractionKind(kind) {
|
|
case models.InteractionKindClarification:
|
|
types = append(types, string(models.MessageTypeClarificationRequest))
|
|
case models.InteractionKindPermission:
|
|
types = append(types, string(models.MessageTypePermissionRequest))
|
|
}
|
|
}
|
|
if len(types) == 0 {
|
|
return "1 = 0", nil
|
|
}
|
|
ph, args := buildInPlaceholders(types)
|
|
return "type IN (" + ph + ")", args
|
|
}
|