mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* feat(daemon): surface the real task initiator to the agent runtime (MUL-2645)
In a multi-person workspace the agent runtime only ever saw the runtime
OWNER identity: the brief's `## Requesting User` is sourced from
runtime.OwnerID and the task-scoped token is owner-bound, so every
requester (whoever commented, @mentioned, or chatted) appeared to the
agent as the owner. Agents that route by initiator for permission,
privacy, or audit all misjudged.
Resolve the real task initiator at claim time and surface it distinctly
from the owner:
- comment / mention trigger -> triggering comment's author (member or agent)
- chat task -> chat session creator (sessions are creator-only)
- on-assign / autopilot / quick-create -> no attributable initiator (omitted)
Adds initiator_{type,id,name,email} to the claim response, the daemon
Task, and TaskContextForEnv, rendered into the brief as a new
`## Task Initiator` section. The section documents the privacy boundary:
the agent's credentials stay owner-scoped, so this is an attested
identity for the agent's own routing/privacy logic, not act-as. No DB
migration — both paths are derivable from existing rows.
Tests: brief rendering (member/agent/omit/sanitize) + email guard unit
tests, and claim-handler tests for the comment and chat paths.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): store real sender as task initiator, not chat_session creator (MUL-2645)
Review fix (Niko, PR #3899). v1 resolved the chat task initiator from
chat_session.creator_id at claim time. That is correct for web chat and
Lark p2p (creator == sender), but WRONG for Lark group chats: the group
session creator is deliberately the installer (stable identity across
member churn), not the message sender. So in a Lark group, every member
who triggered the agent showed up in the brief as the installer/owner —
the exact bug this issue is about, still live at that entry point.
Capture the real sender at enqueue time instead of deriving it from the
session creator at claim time:
- migration 117: agent_task_queue.initiator_user_id (FK user, ON DELETE
SET NULL); NULL for non-chat and pre-migration rows.
- EnqueueChatTask now takes an explicit initiatorUserID. Web chat passes
the authenticated request user; the Lark dispatcher threads the inbound
sender (binding.MulticaUserID) through scheduleRun -> flushChatRun. The
debouncer keeps the latest scheduled flush per session, so in a multi-
sender silence window the LATEST sender wins (documented + tested).
- claim handler resolves the initiator from task.initiator_user_id and
drops the creator_id fallback entirely.
The Lark group session creator stays the installer (unchanged) — only the
task initiator is corrected, keeping the two concepts cleanly separate.
Tests: dispatcher group regression (initiator = sender, not installer),
latest-sender-wins, p2p initiator assertion; the chat claim handler test
now sets creator != initiator and asserts the stored sender wins.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
172 lines
6.9 KiB
SQL
172 lines
6.9 KiB
SQL
-- name: CreateChatSession :one
|
|
INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, runtime_id)
|
|
VALUES ($1, $2, $3, $4, (SELECT runtime_id FROM agent WHERE id = $2))
|
|
RETURNING *;
|
|
|
|
-- name: GetChatSession :one
|
|
SELECT * FROM chat_session
|
|
WHERE id = $1;
|
|
|
|
-- name: GetChatSessionInWorkspace :one
|
|
SELECT * FROM chat_session
|
|
WHERE id = $1 AND workspace_id = $2;
|
|
|
|
-- name: ListChatSessionsByCreator :many
|
|
-- Returns active sessions with a boolean unread flag. Unread is strictly
|
|
-- per-session: either the user has uncleared assistant replies in this
|
|
-- session or they don't. Counting messages would be misleading.
|
|
SELECT cs.*,
|
|
(cs.unread_since IS NOT NULL)::bool AS has_unread
|
|
FROM chat_session cs
|
|
WHERE cs.workspace_id = $1 AND cs.creator_id = $2 AND cs.status = 'active'
|
|
ORDER BY cs.updated_at DESC;
|
|
|
|
-- name: ListAllChatSessionsByCreator :many
|
|
SELECT cs.*,
|
|
(cs.unread_since IS NOT NULL)::bool AS has_unread
|
|
FROM chat_session cs
|
|
WHERE cs.workspace_id = $1 AND cs.creator_id = $2
|
|
ORDER BY cs.updated_at DESC;
|
|
|
|
-- name: UpdateChatSessionTitle :one
|
|
UPDATE chat_session SET title = $2, updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateChatSessionSession :exec
|
|
-- Updates the resume pointer for a chat session. Empty/NULL inputs are
|
|
-- ignored via COALESCE so a task that completes without a session_id (e.g.
|
|
-- the agent crashed before establishing one) cannot wipe out a previously
|
|
-- recorded resume pointer. This makes the chat memory robust against
|
|
-- intermittent agent failures.
|
|
UPDATE chat_session
|
|
SET session_id = COALESCE(sqlc.narg('session_id'), session_id),
|
|
work_dir = COALESCE(sqlc.narg('work_dir'), work_dir),
|
|
runtime_id = COALESCE(sqlc.narg('runtime_id'), runtime_id),
|
|
updated_at = now()
|
|
WHERE id = sqlc.arg('id');
|
|
|
|
-- name: LockChatSessionForDelete :one
|
|
-- Acquires an exclusive (FOR UPDATE) row lock on chat_session(id). Used by
|
|
-- the delete path so that a concurrent SendChatMessage cannot enqueue a new
|
|
-- agent_task_queue row referencing this session between our cancel and
|
|
-- delete steps. The FK from agent_task_queue.chat_session_id takes a
|
|
-- KEY SHARE lock on the parent row during INSERT validation, which
|
|
-- conflicts with FOR UPDATE — concurrent inserts block here and then fail
|
|
-- their FK check after we commit the delete.
|
|
SELECT id FROM chat_session
|
|
WHERE id = $1
|
|
FOR UPDATE;
|
|
|
|
-- name: DeleteChatSession :exec
|
|
-- Hard delete. chat_message rows cascade via FK ON DELETE CASCADE; the
|
|
-- chat_session_id on agent_task_queue is set NULL by FK so completed/failed
|
|
-- task history survives the session being removed. Callers MUST run inside
|
|
-- the same transaction that holds LockChatSessionForDelete and that has
|
|
-- already cancelled any in-flight tasks (see CancelAgentTasksByChatSession)
|
|
-- so the daemon does not keep running work whose result has nowhere to
|
|
-- land. workspace_id in the WHERE clause is a SQL-layer tenant guard; see
|
|
-- DeleteIssue.
|
|
DELETE FROM chat_session WHERE id = $1 AND workspace_id = $2;
|
|
|
|
-- name: TouchChatSession :exec
|
|
UPDATE chat_session SET updated_at = now()
|
|
WHERE id = $1;
|
|
|
|
-- name: CreateChatMessage :one
|
|
INSERT INTO chat_message (chat_session_id, role, content, task_id, failure_reason, elapsed_ms)
|
|
VALUES ($1, $2, $3, sqlc.narg(task_id), sqlc.narg(failure_reason), sqlc.narg(elapsed_ms))
|
|
RETURNING *;
|
|
|
|
-- name: ListChatMessages :many
|
|
SELECT * FROM chat_message
|
|
WHERE chat_session_id = $1
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: ListChatMessagesPage :many
|
|
SELECT * FROM chat_message
|
|
WHERE chat_session_id = $1
|
|
AND (
|
|
sqlc.narg('before_created_at')::timestamptz IS NULL
|
|
OR (created_at, id) < (sqlc.narg('before_created_at')::timestamptz, sqlc.narg('before_id')::uuid)
|
|
)
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT $2;
|
|
|
|
-- name: GetChatMessage :one
|
|
SELECT * FROM chat_message
|
|
WHERE id = $1;
|
|
|
|
-- name: CreateChatTask :one
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, chat_session_id, initiator_user_id)
|
|
VALUES ($1, $2, NULL, 'queued', $3, $4, $5)
|
|
RETURNING *;
|
|
|
|
-- name: GetLastChatTaskSession :one
|
|
-- Returns the most recent task in this chat session that managed to record a
|
|
-- session_id. Includes both completed and failed tasks: even a failed task
|
|
-- may have established a real agent session before failing, and we'd rather
|
|
-- resume there than start over and lose conversation memory. Used as a
|
|
-- fallback when chat_session.session_id is NULL. Resume-unsafe failures are
|
|
-- excluded because replaying those sessions deterministically reproduces the
|
|
-- same terminal state.
|
|
SELECT session_id, work_dir, runtime_id FROM agent_task_queue
|
|
WHERE chat_session_id = $1
|
|
AND (
|
|
status = 'completed'
|
|
OR (
|
|
status = 'failed'
|
|
AND COALESCE(failure_reason, '') NOT IN ('iteration_limit', 'agent_fallback_message', 'api_invalid_request', 'codex_semantic_inactivity')
|
|
AND NOT (COALESCE(error, '') ILIKE '%400%' AND COALESCE(error, '') ILIKE '%invalid_request_error%')
|
|
)
|
|
)
|
|
AND session_id IS NOT NULL
|
|
ORDER BY completed_at DESC
|
|
LIMIT 1;
|
|
|
|
-- name: GetPendingChatTask :one
|
|
-- Returns the most recent in-flight task for a chat session, if any.
|
|
-- Used by the frontend to recover pending state after refresh / reopen.
|
|
-- created_at is the anchor for the chat StatusPill timer (it computes
|
|
-- elapsed = now - task.created_at), so the pill survives refresh / reopen
|
|
-- without "resetting to 0s".
|
|
SELECT id, status, created_at FROM agent_task_queue
|
|
WHERE chat_session_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
|
ORDER BY created_at DESC
|
|
LIMIT 1;
|
|
|
|
-- name: ListPendingChatTasksByCreator :many
|
|
-- Aggregate view of all in-flight chat tasks owned by a given creator in a
|
|
-- workspace. Drives the FAB's "running" indicator when the chat window is
|
|
-- closed and no single session's query is active.
|
|
SELECT atq.id AS task_id, atq.status, atq.chat_session_id
|
|
FROM agent_task_queue atq
|
|
JOIN chat_session cs ON cs.id = atq.chat_session_id
|
|
WHERE cs.workspace_id = $1
|
|
AND cs.creator_id = $2
|
|
AND atq.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
|
ORDER BY atq.created_at DESC;
|
|
|
|
-- name: MarkChatSessionRead :exec
|
|
-- Clears unread_since, dropping the session's unread count to 0.
|
|
UPDATE chat_session SET unread_since = NULL
|
|
WHERE id = $1;
|
|
|
|
-- name: SetUnreadSinceIfNull :exec
|
|
-- Atomically stamps the first unread assistant message's arrival time.
|
|
-- No-op if the session is already in "has unread" state — keeps the earliest
|
|
-- unread boundary stable across multiple incoming replies.
|
|
UPDATE chat_session SET unread_since = now()
|
|
WHERE id = $1 AND unread_since IS NULL;
|
|
|
|
-- name: GetMostRecentUserChatMessage :one
|
|
-- Returns the most recent role='user' message in a session. Used by the
|
|
-- Lark `/issue` command parser: when the user types `/issue` with no
|
|
-- title, the spec falls back to "use the previous user message as the
|
|
-- title". Bot replies (role='assistant') are excluded — only human
|
|
-- input qualifies as a fallback title source.
|
|
SELECT * FROM chat_message
|
|
WHERE chat_session_id = $1 AND role = 'user'
|
|
ORDER BY created_at DESC
|
|
LIMIT 1;
|