ROLLICA · 根因重查 · BUG-7 交接 · 2026-08-07

提单助手不是没挂图,是服务端不允许它挂

TL;DR
issue create --attachment-id 的绑定 SQL 要求 附件上传者 == Issue 创建者。 图是人类成员上传的,Issue 是 agent 创建的,两者不相等 → UPDATE 命中 0 行 → 绑定静默失败, 而 Issue 照常创建成功、退出码 0、无任何报错。
提单助手的命令一字不差,且正是平台 prompt 明确指示的写法 —— 这是平台契约自相矛盾,不是 agent 出错。
前序结论已被推翻
「attachment id 已失效/已删除」不成立。该附件从 20:24:42 创建至今一直存活,我在 22:20 用同一个 id 成功绑定到了新建 issue。前序结论的唯一证据 —— attachment download 返回 404 —— 是 任务作用域造成的假阴性
01

根因:一条 WHERE 子句

服务端把「Issue 创建者」当成「附件上传者」去过滤要绑定的附件。对 agent 建单场景,这个等式永远不成立。

1 · 平台 prompt prompt.go:398 「用 --attachment-id」 2 · CLI 转发 cmd_issue.go:1148 body["attachment_ids"] 3 · handler issue.go:2399 CreatorType = "agent" 4 · service issue.go:294 UploaderType = 创建者 5 · LinkAttachmentsToIssue · attachment.sql:340 WHERE issue_id IS NULL AND uploader_type = 'agent' AND uploader_id = <agent> 但真实行是 uploader_type = 'member' · uploader_id = <人类> → 0 rows 6 · 0 行更新不是错误 · err == nil · 且错误本就被 swallow Issue 创建成功 · exit 0 · 返回体无 attachments · agent 如实汇报「已挂上」 代入 BUG-3 附件 uploader_type = member (9e8a3719…) BUG-3 creator_type = agent (cada05b8…) 'member' = 'agent' → false → 永不匹配
Figure 1 · 平台指示 agent 走一条服务端注定拒绝的路径,且三层全部吞掉失败信号。

关键概念界定

uploader(上传者)
attachment 行上的 uploader_type / uploader_id,在文件上传那一刻写死,指向真正把字节传上来的主体。人类在 chat 里贴图 → member
creator(Issue 创建者)
issue.creator_type / creator_id,由服务端按当前 task 的 actor 解析。agent task 里跑 CLI → agent。不能由客户端 env 伪造(实验 3 已验证)。
静默失败
此处特指:HTTP 200 + CLI exit 0 + 返回体缺少 attachments 字段,没有任何 stderr、warning 或错误码。调用方(含 agent)无法从接口观测到失败。
任务作用域(task-scoped)
multica attachment download 只能取到授予当前 task 的附件。对其它 task 的活附件一律返回 404 —— 这是权限边界,不是存在性判定。
02

前序结论为什么是错的

前序结论:「attachment id 019fdc2e-… 在服务端已失效/已删除,server 静默跳过绑定」。它的唯一证据是一次 404。

$ multica attachment download 019fdc2e-cceb-765b-abc5-f5bd713471bd
The requested resource was not found.

我把这个方法用在两个已被 issue get 证明活着的附件上,得到同样的 404:

attachment id服务端真实状态download 结果
019fdc33-a8eb-7588-…存活,绑在 BUG-5,有 download_urlrc=4 not found
019fdc34-499e-7a9e-…存活,绑在 BUG-4,有 markdown_urlrc=4 not found
019fdc2e-cceb-765b-…存活(见下方直接反证)rc=4 not found

所以 404 只能推出「本 task 没被授予这个附件」。更直接的反证 —— 我用同一个 id 成功绑定:

$ multica issue create … --attachment-id 019fdc2e-cceb-765b-abc5-f5bd713471bd
→ BUG-8, attachments: [{
    "id": "019fdc2e-cceb-765b-abc5-f5bd713471bd",
    "filename": "img_v3_0214b_42ecb616-…-44831a71308g.gif",
    "created_at": "2026-08-07T20:24:42+08:00",
    "chat_session_id": "5d037921-3062-4f3b-a8ef-52760dfd2a49",
    "uploader_type": "member",
    "uploader_id":   "9e8a3719-73f1-4435-8223-71ffb6054967",
    "size_bytes": 2825604
  }]

该附件从创建至今一直存活、一直可绑定。用户「结论完全是错的」这个判断是对的。

为什么前序的 BUG-5 复现实验「成功」了,却反而掩盖了根因

BUG-5 是以成员身份创建的(creator_type: member),而它绑的附件 uploader 也是同一个成员 —— 恰好满足 uploader == creator。所以它绕开了真正的失败条件。

那次实验同时更换了两个变量(attachment id 不同 + 主体身份不同),不是对照实验。它得出「换个 id 就行 → 所以老 id 坏了」,把身份这个真变量归因到了 id 上。

03

提单助手当时到底做了什么

~/Library/Caches/trae-cli/sessions/258f3c92-73cc-4b3a-b9c1-689b8ec8912f/events.jsonl

平台注入给它的 prompt(idx 8)

Attachments on this message:
- id=019fdc2e-cceb-765b-abc5-f5bd713471bd filename="img_v3_…gif" content_type=image/gif
Use `multica attachment download <id>` to fetch each file locally before referring to it.
When creating an issue that should preserve one of these attachments, pass
`--attachment-id <id>` to `multica issue create` in addition to keeping the
attachment markdown inline.

它执行的命令(idx 24 / idx 27)

# 20:25:36 第一次 —— 被去重守卫拒绝
multica issue create --title "[v0.0.3 Bug] mermaid 有 bug" \
  --description-file ./description.md --status backlog \
  --attachment-id 019fdc2e-cceb-765b-abc5-f5bd713471bd --output json
→ Active duplicate issue exists: BUG-2 …

# 20:25:41 第二次 —— 加 --allow-duplicate,成功
multica issue create --title "[v0.0.3 Bug] mermaid 有 bug" \
  --description-file ./description.md --status backlog \
  --attachment-id 019fdc2e-cceb-765b-abc5-f5bd713471bd \
  --allow-duplicate --output json
→ {"identifier":"BUG-3",
   "creator_type":"agent",
   "creator_id":"cada05b8-b1fd-48a8-8d04-450cdd7d8d9f", … }
   # 返回体里完全没有 attachments 字段

参数一字不差,没有报错,没有 hallucinate。唯一异常是返回体静默缺少 attachments。关键字段就是那行 creator_type: "agent"

04

服务端代码链

以下均取自部署中的 commit 520e45a2e(与 CLI v0.0.2 一致),仅只读查看,未改动任何代码。

4 · service 把「Issue 创建者」当「附件上传者」 ← 缺陷点

server/internal/service/issue.go:294

func (s *IssueService) linkAttachments(ctx context.Context, issue db.Issue, ids []pgtype.UUID) []db.Attachment {
    if len(ids) == 0 { return nil }
    if err := s.Queries.LinkAttachmentsToIssue(ctx, db.LinkAttachmentsToIssueParams{
        IssueID:      issue.ID,
        WorkspaceID:  issue.WorkspaceID,
        UploaderType: issue.CreatorType,   // ← 把「Issue 创建者」当成「附件上传者」
        UploaderID:   issue.CreatorID,     // ←
        AttachmentIds: ids,
    }); err != nil {
        slog.Error("failed to link attachments to issue", …)   // 只记日志,不影响返回
        return nil
    }
    …
}

函数注释本身就写明这是「best-effort post-commit step」—— 设计上就是失败也不告诉调用方。

5 · SQL 的硬条件

server/pkg/db/queries/attachment.sql:340

-- name: LinkAttachmentsToIssue :exec
UPDATE attachment
SET issue_id = sqlc.arg(issue_id),
    expires_at = NULL
WHERE workspace_id  = sqlc.arg(workspace_id)
  AND issue_id IS NULL                        -- 条件 A:必须尚未绑定任何 issue
  AND uploader_type = sqlc.arg(uploader_type) -- 条件 B:上传者类型 == Issue 创建者类型
  AND uploader_id   = sqlc.arg(uploader_id)   -- 条件 C:上传者 id   == Issue 创建者 id
  AND id = ANY(sqlc.arg(attachment_ids)::uuid[]);
字段BUG-3 实际值条件 B 判定
附件 uploader_type / uploader_idmember / 9e8a3719-…(人类)'member' = 'agent'
→ false → 0 rows
BUG-3 creator_type / creator_idagent / cada05b8-…(提单助手)
这个 uploader 过滤不是 bug,是故意加的安全措施 —— 但它漏了一个合法场景

仓库里有专门的安全测试 server/internal/handler/attachment_link_security_test.goTestLinkAttachmentsToIssueRequiresOriginalUploader,防止「成员 A 猜到成员 B 的 attachment UUID 就把它据为己有」。file.go:1499 的注释也写明:「Only the original uploader may claim an unbound staged attachment」

问题在于这条规则没有为「agent 代人类建单」开口子,而平台的 prompt 又恰恰要求 agent 走这条路。安全意图正确,覆盖面不完整。

其它相关代码位置
  • server/internal/daemon/prompt.go:398 —— 生成那句「pass --attachment-id」指示,条件是 task.TargetType != taskTargetRuntime
  • server/cmd/multica/cmd_issue.go:1148 —— CLI 把 --attachment-idMULTICA_QUICK_CREATE_ATTACHMENT_IDS 合并进 body["attachment_ids"]
  • server/internal/handler/issue.go:2286 / 2399 / 2408 —— 解析 attachment_ids,并把 CreatorType 设为当前 actor。
  • server/internal/handler/issue.go:2668-2687 —— issue updateresolveActor + 同一个 query,对 agent 同样失效。
05

受控实验

全部以成员身份在 workspace 470e8a4a-… 执行。探测 issue 一律 --status cancelled、无 assignee,不上看板、不触发任何 agent。

#操作实测结果证明了什么
1 create --attachment-id 019fdc2e-…
(成员建单,uploader 也是该成员)
BUG-8,attachments 有 1 条 附件存活且可绑定 → 「已失效」结论被推翻
2 同一次 create 里附带
--attachment-id 00000000-0000-0000-0000-000000000000
静默丢弃,无报错,rc=0 --attachment-id 无任何校验反馈,不可解析的 id 一律静默吞掉
3 MULTICA_AGENT_ID=cada05b8-… create --attachment-id 019fdc2e-… BUG-9,creator_type 仍是 member,attachments 为 null agent 身份不能由客户端 env 伪造(服务端按 task 解析 actor);此处 null 由条件 A 造成
4 去掉 env 重跑同一 id(对照) BUG-10,同样 attachments 为 null 实验 3 的 null 与 MULTICA_AGENT_ID 无关 —— 它是干扰项,必须排除
5 三个已知存活附件全部 attachment download 全部 rc=4 not found download 404 是任务作用域假阴性,不能当作附件不存在的证据

实验 3+4 顺带确认了条件 A 的真实语义:附件一旦绑定到某个 issue,后续 --attachment-id 传同一个 id 会被静默丢弃。一个附件只能绑一个 issue。

06

为什么「贴评论就能生效」

这是用户的核心疑问。答案是两条路径的 SQL 条件完全不同 —— 评论侧根本没有 uploader 过滤。

server/pkg/db/queries/attachment.sql:174

-- name: LinkAttachmentsToComment :exec
UPDATE attachment
SET comment_id = $1, expires_at = NULL
WHERE issue_id = $2
  AND comment_id IS NULL
  AND id = ANY($3::uuid[]);
-- 注意:没有 uploader_type / uploader_id 条件

而且 comment add --attachment <本地路径> 是先上传一个新文件(uploader = 调用方自己),再绑定 —— 天然满足所有条件。

路径uploader 过滤agent 代人类挂图
issue create --attachment-id <已有 id>有(uploader == issue creator永远失败,且静默
issue update --attachment-id <已有 id>有(同一个 query)同样失败
comment add --attachment <本地路径>成功
issue create --attachment <本地路径>有,但上传者就是调用方成功

所以对 agent 而言,唯一可靠的挂图方式是先 multica attachment download <id> 落到 workdir,再用 --attachment <本地路径>(BUG-4 就是这么成功的)。而平台 prompt 却明确指示它用 --attachment-id

07

影响面

  1. 所有 agent 建单挂对话附件的场景全部静默丢图。不限于提单助手 —— 任何 agent task,只要按 prompt 指示用 --attachment-id 挂人类上传的附件,100% 失败且无感知。这解释了 BUG-2 的同类现象(同模板、同 agent)。
  2. quick-create 流程同样中招(代码层推断,未做线上复现)。daemon.go:4588 把人类上传的附件 id 经 MULTICA_QUICK_CREATE_ATTACHMENT_IDS 交给 agent,cmd_issue.go:1149 自动并入 attachment_ids,但 issue 由 agent 创建 → 同一个 uploader 不匹配 → 静默丢弃。该 env 的测试(daemon_test.go:4737)只断言「env 被正确传递」,没有断言「附件最终绑上了」
  3. issue update --attachment-id 对 agent 一样失效issue.go:2668-2687,同一个 query)。
  4. 没有任何错误信号。三层都在吞:SQL 命中 0 行不算错、linkAttachmentsslog.Error、handler 不校验请求的 id 是否真的绑上了。agent 因此必然汇报成功 —— 这不是模型幻觉,是接口没有给出可观测的失败。
08

修复方向

供产品/研发决策,本次未实施任何修复。按优先级:

  1. 让绑定授权认「同一 task / 同一 chat session 的上下文」,而不是只认 uploader 相等。 agent 在 task fea75f5c 里创建 issue,附件 019fdc2e 属于该 task 的 chat session 5d037921 —— 这是合法授权关系。给 LinkAttachmentsToIssue 增加一条「附件的 chat_session_id/task_id 属于当前 task」的放行分支,既修好 agent 场景,又不放开「猜 UUID 偷别人附件」(原安全测试仍然通过,因为陌生成员的 staged 附件不在你的 task 里)。
  2. 把绑定结果回传给调用方,不要静默。LinkAttachmentsToIssue 改成 :execrows,或返回实际绑定的 id 列表。chat 侧已有现成先例:chat.go:700BoundAttachmentIDs 明确区分「请求的」和「实际绑上的」。CLI 在 attachment_ids 非空而 attachments 为空时应当 warn 到 stderr。
  3. 短期兜底:改 prompt。prompt.go:398 的指示改成「先 multica attachment download <id>,再用 --attachment <本地路径>」—— 这条路径实测可用,无需动服务端。
09

本次排查对环境做过什么

⚠️ 一个必须知道的副作用
BUG-8 现在持有用户原始那张 GIF 的附件绑定019fdc2e-…,实验 1 的副作用)。
不要删除 BUG-8 —— deleteIssue 会 CASCADE 删除附件行并清理 S3 对象(issue.go:2960-2972),那会真正销毁这张原图。
想让 BUG-3 拿到图,请用本地已有的副本重新上传(9a943be1/workdir/img_v3_…gif),不要去动 BUG-8。