Files
dfa8bdaa53 MUL-8070: feat(cursor): support live-run steering via Cursor hooks (#9322)
* MUL-8070: feat(cursor): support live-run steering via Cursor hooks

Cursor's headless --print mode has no mid-turn input channel, but its
postToolUse/postToolUseFailure hooks can return additional_context that
reaches the model in the same turn. A steerable Cursor run now writes a
managed .cursor/hooks.json whose command is the multica binary in a hidden
relay mode; the relay forwards the hook event to a token-protected loopback
endpoint owned by the run, which hands back pending guidance tagged with a
per-run nonce. The prompt declares that tag so the model treats the context
as genuine user guidance instead of a prompt injection.

Steering is offered only for daemon-managed workdirs on Unix with cursor-agent
2026.10.14+, and the server allowlists cursor for capability negotiation,
supplement creation and composer binding.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* MUL-8070: fix(cursor): confirm steering only on Cursor's receipt and gate on installable hooks

- Count hook-delivered guidance as delivered only when the tool call's
  completed stream event echoes it in hookAdditionalContexts. Guidance
  Cursor dropped for the same call is retried at the next tool boundary;
  guidance without a receipt fails when the turn ends.
- Offer steering only when the workdir can carry the managed hooks.json,
  and fail supplements immediately if installation still fails after
  negotiation.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-18 16:23:05 +08:00

271 lines
9.8 KiB
SQL

-- name: StartAgentTaskWithSupplement :one
-- Starting the task and recording the exact daemon/server capability handshake
-- are one state transition. A missing row is the fail-closed value for old
-- daemons, old servers, unsupported providers and application rollback.
WITH candidate AS MATERIALIZED (
SELECT t.id, t.issue_id, r.workspace_id, r.provider
FROM agent_task_queue t
JOIN agent_runtime r ON r.id = t.runtime_id
WHERE t.id = @task_id
AND t.status IN ('dispatched', 'waiting_local_directory')
FOR UPDATE OF t
), capability AS (
INSERT INTO task_supplement_capability (task_id, workspace_id, issue_id, capability)
SELECT id, workspace_id, issue_id, 'task-supplement-v1'
FROM candidate
WHERE @enable_task_supplement::boolean
AND provider IN ('codex', 'claude', 'grok', 'cursor')
AND issue_id IS NOT NULL
ON CONFLICT DO NOTHING
RETURNING task_id
)
UPDATE agent_task_queue t
SET status = 'running',
started_at = now(),
wait_reason = NULL,
prepare_lease_expires_at = NULL
FROM candidate
WHERE t.id = candidate.id
-- Reference the data-modifying CTE explicitly: capability persistence and
-- the returned running row are one indivisible statement.
AND (SELECT count(*) FROM capability) >= 0
RETURNING t.*;
-- name: CreateTaskSupplement :one
-- Locking the exact task serializes against terminal transitions.
-- Comment creation, explicit task binding and run coverage then
-- commit as one statement: a terminal-race loser creates nothing.
WITH locked_task AS MATERIALIZED (
SELECT t.id, t.issue_id, t.agent_id, t.trigger_comment_id
FROM agent_task_queue t
JOIN agent_runtime r ON r.id = t.runtime_id
JOIN task_supplement_capability cap ON cap.task_id = t.id
WHERE t.id = @task_id
AND t.issue_id = @issue_id
AND r.workspace_id = @workspace_id
AND t.status = 'running'
AND cap.capability = 'task-supplement-v1'
AND r.provider IN ('codex', 'claude', 'grok', 'cursor')
FOR UPDATE OF t
), touched_issue AS (
UPDATE issue i SET
updated_at = now(),
revision = revision + 1,
last_activity_at = GREATEST(COALESCE(last_activity_at, updated_at), now())
FROM locked_task t
WHERE i.id = t.issue_id AND i.workspace_id = @workspace_id
RETURNING i.id, i.workspace_id, i.revision
), inserted_comment AS (
INSERT INTO comment (
issue_id, workspace_id, author_type, author_id, content, type, parent_id
)
SELECT i.id, i.workspace_id, 'member', @author_id, @content, 'comment', t.trigger_comment_id
FROM touched_issue i
JOIN locked_task t ON t.issue_id = i.id
RETURNING *
), inserted_supplement AS (
INSERT INTO task_supplement (
task_id, workspace_id, issue_id, comment_id, author_id,
client_request_id, status
)
SELECT t.id, i.workspace_id, i.id, c.id, @author_id,
@client_request_id,
'pending'
FROM locked_task t
JOIN touched_issue i ON i.id = t.issue_id
JOIN inserted_comment c ON c.issue_id = i.id
RETURNING *
)
SELECT c.*, i.revision AS issue_revision,
s.task_id AS supplement_task_id, s.status AS supplement_status,
s.failure_reason AS supplement_failure_reason,
s.delivered_at AS supplement_delivered_at
FROM inserted_comment c
JOIN touched_issue i ON i.id = c.issue_id
JOIN inserted_supplement s ON s.comment_id = c.id;
-- name: GetTaskSupplementByRequest :one
SELECT s.*
FROM task_supplement s
WHERE s.task_id = @task_id
AND s.workspace_id = @workspace_id
AND s.author_id = @author_id
AND s.client_request_id = @client_request_id;
-- name: BindCommentTaskSupplement :one
-- Binds an ordinary member comment, already created through the normal comment
-- path, to the exact running turn its author chose. Locking the task
-- serializes against terminal transitions: when that turn ended first nothing
-- is bound — never a later turn of the same agent — and the caller keeps the
-- comment's normal trigger instead.
WITH locked_task AS MATERIALIZED (
SELECT t.id, t.issue_id, t.runtime_id
FROM agent_task_queue t
JOIN agent_runtime r ON r.id = t.runtime_id
JOIN task_supplement_capability cap ON cap.task_id = t.id
WHERE t.id = @task_id
AND t.issue_id = @issue_id
AND t.agent_id = @agent_id
AND r.workspace_id = @workspace_id
AND t.status = 'running'
AND cap.capability = 'task-supplement-v1'
AND r.provider IN ('codex', 'claude', 'cursor')
FOR UPDATE OF t
), inserted AS (
INSERT INTO task_supplement (
task_id, workspace_id, issue_id, comment_id, author_id,
client_request_id, status
)
SELECT t.id, @workspace_id, t.issue_id, @comment_id, @author_id,
@client_request_id, 'pending'
FROM locked_task t
RETURNING *
)
SELECT inserted.*, locked_task.runtime_id
FROM inserted
JOIN locked_task ON locked_task.id = inserted.task_id;
-- name: GetTaskSupplementForRun :one
SELECT * FROM task_supplement
WHERE comment_id = @comment_id AND task_id = @task_id AND workspace_id = @workspace_id;
-- name: CommentHasTaskSupplement :one
SELECT EXISTS (
SELECT 1 FROM task_supplement
WHERE comment_id = @comment_id AND workspace_id = @workspace_id
) AS bound;
-- name: GetTaskSupplementCapability :one
SELECT * FROM task_supplement_capability WHERE task_id = @task_id;
-- name: ListTaskSupplementsByCommentIDs :many
-- One comment may steer several runs; receipts carry the run's agent so a
-- client can say whose turn received it.
SELECT s.*, t.agent_id
FROM task_supplement s
LEFT JOIN agent_task_queue t ON t.id = s.task_id
WHERE s.workspace_id = @workspace_id
AND s.comment_id = ANY(@comment_ids::uuid[])
ORDER BY s.created_at, s.task_id;
-- name: ListTaskSupplementMetadata :many
SELECT cap.task_id, cap.capability,
COALESCE(array_agg(s.comment_id ORDER BY s.created_at, s.comment_id)
FILTER (WHERE s.comment_id IS NOT NULL), '{}'::uuid[])::uuid[] AS comment_ids
FROM task_supplement_capability cap
LEFT JOIN task_supplement s ON s.task_id = cap.task_id
WHERE cap.workspace_id = @workspace_id
AND cap.task_id = ANY(@task_ids::uuid[])
GROUP BY cap.task_id, cap.capability;
-- name: SettleTerminalTaskSupplements :execrows
-- Application terminal transitions call this in the same transaction as the
-- agent_task_queue update. Re-checking the persisted task status makes an
-- accidental early call a no-op while preserving the task-row -> supplement
-- lock order used by creation and delivery acknowledgement.
UPDATE task_supplement AS supplement
SET status = 'failed',
failure_reason = 'turn_ended',
updated_at = now()
FROM agent_task_queue AS task
WHERE supplement.task_id = task.id
AND task.id = ANY(@task_ids::uuid[])
AND task.status IN ('completed', 'failed', 'cancelled')
AND supplement.status IN ('pending', 'delivering');
-- name: ClaimNextTaskSupplement :one
WITH next AS MATERIALIZED (
SELECT s.comment_id, s.task_id
FROM task_supplement s
JOIN agent_task_queue t ON t.id = s.task_id
JOIN task_supplement_capability cap ON cap.task_id = t.id
WHERE s.task_id = @task_id
AND s.status = 'pending'
AND t.status = 'running'
AND cap.capability = 'task-supplement-v1'
ORDER BY s.created_at, s.comment_id
FOR UPDATE OF s SKIP LOCKED
LIMIT 1
), claimed AS (
UPDATE task_supplement s
SET status = 'delivering',
attempt_count = attempt_count + 1,
failure_reason = NULL,
updated_at = now()
FROM next
WHERE s.comment_id = next.comment_id
AND s.task_id = next.task_id
RETURNING s.*
)
SELECT claimed.comment_id, claimed.attempt_count, c.content,
COALESCE(NULLIF(btrim(u.name), ''), 'a user')::text AS author_name
FROM claimed
JOIN comment c ON c.id = claimed.comment_id
LEFT JOIN "user" u ON u.id = claimed.author_id;
-- name: AckTaskSupplementDelivered :one
-- The provider can accept input before completion while its HTTP acknowledgement
-- arrives afterward. Serialize with settlement and preserve that successful write.
WITH task AS MATERIALIZED (
SELECT id FROM agent_task_queue WHERE id = @task_id FOR UPDATE
)
UPDATE task_supplement s
SET status = 'delivered',
delivered_at = COALESCE(delivered_at, now()),
failure_reason = NULL,
updated_at = now()
FROM task t
WHERE s.task_id = t.id
AND s.comment_id = @comment_id
AND (s.status IN ('delivering', 'delivered')
OR (s.status = 'failed' AND s.failure_reason = 'turn_ended' AND s.attempt_count > 0))
RETURNING s.*;
-- name: AckTaskSupplementFailed :one
UPDATE task_supplement
SET status = 'failed',
failure_reason = @failure_reason,
updated_at = now()
WHERE task_id = @task_id
AND comment_id = @comment_id
AND status = 'delivering'
RETURNING *;
-- name: RetryTaskSupplement :one
WITH comment AS MATERIALIZED (
SELECT id FROM comment
WHERE id = @comment_id AND workspace_id = @workspace_id AND deleted_at IS NULL
FOR UPDATE
), active_task AS MATERIALIZED (
SELECT agent_task_queue.id FROM agent_task_queue
WHERE agent_task_queue.id = @task_id
AND agent_task_queue.issue_id = @issue_id
AND agent_task_queue.status = 'running'
AND EXISTS (SELECT 1 FROM comment)
AND EXISTS (
SELECT 1 FROM task_supplement_capability cap
WHERE cap.task_id = agent_task_queue.id
AND cap.capability = 'task-supplement-v1'
)
FOR UPDATE OF agent_task_queue
)
UPDATE task_supplement s
SET status = 'pending',
failure_reason = NULL,
updated_at = now()
FROM active_task t
WHERE s.task_id = t.id
AND s.comment_id = @comment_id
AND s.workspace_id = @workspace_id
AND s.status = 'failed'
RETURNING s.*;
-- name: LockTaskSupplementsByComment :many
SELECT * FROM task_supplement
WHERE comment_id = @comment_id AND workspace_id = @workspace_id
FOR UPDATE;
-- name: DeleteTaskSupplementByComment :exec
DELETE FROM task_supplement
WHERE comment_id = @comment_id AND workspace_id = @workspace_id;