背景报告 · MULTICA 任务派发与会话机制 · 2026-07-12

一切皆 task:Multica 的派发、resume 与工作目录到底怎么运作

Chat 消息、issue 评论、autopilot——在 Multica 里全都被建成一条 task,进入同一个队列,由本机 daemon claim 后新起一个 agent 子进程执行。上下文的连续性靠 --resume,物理隔离靠按 task 建、按 (agent,issue) 复用的 workdir。这份文档把 chat / issue 两条链路、resume 的边界、工作目录逻辑、父子 issue 关系一次讲透,全部有代码出处。

一句话
模型
每条消息/评论 = 一个 task = 一次新进程(进程不跨轮保活)。上下文靠 --resume 续:chat 按 chat_session_id 续,issue 按 (agent_id, issue_id) 续。workdir 按 task 建、按 (agent,issue) 复用——同一 issue 下不同 agent 各有各的目录和会话,父子 issue 之间物理完全独立,只有元数据关联。
01

统一骨架:所有入口都汇成一条 task

Web 聊天、issue 评论、飞书/Slack、autopilot 定时——入口不同,落库都是 agent_task_queue 里的一行。

入口 (server 侧) Web 聊天 Issue 评论/指派 飞书 / Slack Autopilot 定时 agent_task_queue 一行 = 一个 task agent_id · issue_id · chat_session_id session_id · work_dir · force_fresh_session 本机 Daemon claim task (串行/并发) 解析 PriorSessionID Prepare / Reuse workdir WS claim 新起 Agent 进程 claude / codex / kiro… --resume <sessionID> 跑完即退出 关键不变量 · 进程不跨轮保活:每个 task 一个新进程,报完结果就退出 · 上下文连续性 = 下一个 task 带 --resume 恢复上一轮会话 · 同一会话的 task 串行执行(chat 按 chat_session_id 序列化)
证据:chat.SendChatMessage 返回 {MessageID, TaskID};service/task.goenqueueIssueTask;daemon claim handler handler/daemon.go
02

Chat 的 resume:按 chat_session_id 续

每条消息独立成 task,严格不被"吸收"

  • 发一条消息 → 建一个 chat task(CreateChatTask),绑 chat_session_id
  • 输入归属契约(MUL-4351):每个 task 只拥有它自己创建的那条用户消息。测试 TestDirectChat_TaskOwnsItsOwnInputBatch 钉死 U1→T1、U2→T2,不会合并成 "U1\n\nU2"。
  • 运行中不吸收新消息:T1 正跑时你发的第二条落到独立的 T2(TestDirectChat_RunningTaskDoesNotAbsorbNewMessage:"second send must create a distinct task")。
  • 同一 chat session 的 task 串行执行——T2 等 T1 完成才轮到。

下一轮怎么找回上下文

  • claim 时读 chat_session 上存的 session_id(要求 RuntimeID 与当前 task 匹配)。
  • 指针缺失/stale/换机器时,回退到 GetLastChatTaskSession(chat_session_id) 取最近一条带 session 的 task——"否则一次失败的轮次会静默丢掉整段对话记忆"。
  • 拿到 PriorSessionID → 新进程 --resume
出处:handler/daemon.go(chat 分支,GetChatSession / GetLastChatTaskSession)。
03

Issue 的派发与 resume:按 (agent_id, issue_id) 续

这是 issue 链路的核心键。resume 查找 = GetLastTaskSession(agent_id, issue_id)

Issue #42 Agent A 的会话线 评论1 → taskA1 (Prepare) 评论2 → taskA2 (Reuse+resume) Agent B 的会话线 评论3 → taskB1 (Prepare) resume 键 = (agent_id, issue_id) taskA2 claim → GetLastTaskSession(A, #42) → 命中 taskA1 的 session → --resume → 复用 taskA1 的 workdir taskB1 claim → GetLastTaskSession(B, #42) → B 无前序 → 全新会话 + 全新 workdir A 与 B 永不共享 session / workdir 同一 (agent, issue) 的连续性 评论1→2→3… 一路 --resume, agent 记得整条 issue 对话。 "Focus on THIS comment" 护栏 防止继承上轮的 "Done." 状态。
GetLastTaskSession(pkg/db/queries/agent.sql)接受 completed 和"非毒化的 failed":一个中途崩溃(被 daemon 重启/runtime 掉线/sweeper 超时孤立)的 task 也可能已经建立了真实会话,resume 它能保住在途上下文(MUL-1128)。
出处:handler/daemon.go:1813(issue 分支)· 注释:"Resume the prior session ... including comment-triggered follow-ups, so the agent keeps the issue's conversation context across turns"。
04

边界:什么情况下不 resume,而是开新会话

以下任意一条成立,就从干净会话重开。

  1. force_fresh_session = true — 手动 rerun(你判定上轮输出不好,RerunIssue)或飞书 /new。daemon 直接跳过整个 resume 查找。
  2. 无前序会话 — 这个 (agent, issue) 第一次跑。
  3. 换了 runtime(换机器) — 要求 prior.RuntimeID == task.RuntimeID。CLI 会话状态存在那台机器本地磁盘(Claude Code 存 ~/.claude/projects/<编码cwd>/),换机器无从 resume。
  4. 上一轮把会话"毒化"了GetLastTaskSession 过滤掉这些失败终态:iteration_limitagent_fallback_messageapi_invalid_request(如超大图片→Anthropic 400)、codex_semantic_inactivity(Codex 卡死)。避免反复复现坏状态。
  5. 工作目录没被复用gateResumeToReusedWorkdir:CLI 的 session 按 cwd 索引,若原 workdir 已不在(issue 完成后 GC / daemon 重装 / 手动清理),回退到新 workdir,daemon 主动丢弃 prior session id——否则拿别的 cwd 的 session 去 resume,CLI 一秒内退出、整个 run 失败。
场景结果
同 agent 同 issue 反复评论(常态)一路 resume 续上下文
点了"重跑"fresh 新会话 + 新 workdir
issue 被另一台 runtime 接走fresh
上一轮 API 400 / Codex 卡死fresh(毒化会话被过滤)
issue 完成很久、workdir 被 GC 后再评论fresh(workdir 未复用)
05

工作目录:按 task 建、按 (agent,issue) 复用

Prepare 建目录(按 task ID 命名) {workspacesRoot}/{workspaceID}/{shortID(taskID)}/ ├── workdir/ ← agent 的 cwd(开局空) ├── output/ ├── logs/ └── codex-home/ (仅 codex) workdir 内容 · 开局,不 clone repo;agent 按需 `multica repo checkout <url>` · 写入 .agent_context/issue_context.md · 写入 CLAUDE.md / AGENTS.md + skills(provider 原生路径) · Cursor 有独立 CURSOR_DATA_DIR;OpenClaw 有 per-task config · 根目录写 daemon marker(fail-closed 守卫) 跨轮复用链(Reuse) 评论1: Preparedir = task1/workdir 评论2: Reuse(dir)复用 task1/workdir 评论3: Reuse(dir)仍是 task1/workdir dir 被 GC → Reuse 返回 nil→ 回退 Prepare 新目录 + fresh 会话

关键结论

  • 目录按 task ID 命名(Prepare),但整条对话的文件留在第一个 task 的 workdir 里,后续 task 用 Reuse 复用同一物理目录。
  • 同一 issue、不同 agent → 不同 workdir。 复用链跟着 (agent_id, issue_id) 走,A 复用 A 的、B 复用 B 的,物理隔离,各自也有独立会话。
  • local_directory 流:若 task 绑定了用户本地目录(project_resource),workdir 直接指向用户自己的路径,GC 永不删它。
  • Reuse 会先回滚上一轮写的 sidecar(issue_context.md / skill 目录),再刷新,避免重复堆积。
出处:execenv.Prepare / execenv.Reuse / PredictRootDir(internal/daemon/execenv/execenv.go)。
06

父子 issue:物理独立,只有元数据关联

物理上完全独立

  • 子 issue 是另一个 issue_id → 自己独立的 (agent, issue_id) resume 线 → 独立的 workdir + 独立的 agent session
  • resume 查找 WHERE agent_id=$1 AND issue_id=$2 从不跨 issue,不会走父子关系。
  • 没有任何"读父 issue 的 workdir / 继承父会话"的机制。

只有逻辑 / 元数据层面的关联

  • DB 外键 issue.parent_issue_id
  • 子 issue 继承父 issue 的 project(TestCreateSubIssueInheritsParentProject)。
  • prompt 里有 sub-issue 段落,指示 agent "closing out child work / notify the parent"。
  • issue_child_done.go:子 issue 完成时向父 issue 汇总/通知。
  • 跨 issue 的上下文只以文本传递(handoff_note,或 agent 用 multica issue 命令去读另一个 issue),绝不通过共享文件系统或共享会话
收束
把父子 issue 理解成两个独立的工作区 + 两条独立的对话,它们之间靠"看板上的引用 + 文字交接"协作,而不是靠共享磁盘或共享 agent 记忆。这跟"同一 issue 内不同 agent 也各自独立"是同一个设计哲学:隔离是默认,连续性靠 (agent,issue) 键显式续