题记:任务可以晚一点执行,但不能悄悄换掉“凭什么执行”的出处。

1. 聊天往前走,任务还在昨天⌗

设想一个虚构场景:主对话派出后台任务,要求只读分析一份配置。任务运行期间,用户继续讨论另一项工作,并同意修改另一份文件。

等后台任务走到写入检查时,框架沿着父线程寻找“用户最近说过什么”,找到了那句同意修改的指令。

字是用户写的,查询也没有造假。错在时间关系:这句话出现时,那个任务已经派出去了。

还有一种反方向的错误:用户确实给了指令,任务也据此启动,但主对话随后压缩、换代。检查器只读父线程的当前代,于是原本存在的依据消失了。

这两个问题看似一个管得太松、一个管得太严,其实是同一个设计错误:把一个有生命周期的任务,绑定在一扇不断移动的聊天窗口上。

这次改造不让模型更努力地理解上下文,而是改变证据集合的生成时机:派发时记录消息引用,运行时按引用取证。

2. 从追父线程,改成带着出处走⌗

旧实现位于 src/write-routing/max-quote.ts:查询沿任务的派生关系递归上溯,并读取各线程 current_session_id 下的近期人类消息。

它能回答“现在沿这条父子链能找到什么”,却不能可靠回答“这个任务派发时依据什么”。

新实现给任务表增加了一个字段:

dispatch_message_ids text[] NOT NULL DEFAULT '{}'

这里存的是消息 ID,不是模型总结,不是主线程转述,也不是一段重新拼接的授权文案。

派发任务
    │
    ├─ 从指定父会话选取真实人类消息
    ├─ 合并父任务已持有的来源引用
    └─ 把消息 ID 写入任务记录
                 │
                 ▼
任务执行时
    ├─ 读取自己的近期人类消息
    └─ 按冻结 ID 读取派发来源
          └─ 再检查可见性与消息来源类型

初次创建任务时,这个捕获动作与任务身份创建处在同一事务中,见 src/dispatch/task-store.ts。它不是任务启动后再补做的一次历史搜索。

快照属于任务身份,而不是任务当前会话代。因此,子任务换代不会天然丢掉来源;父线程换代也不会把来源替换成另一批消息。

这里的“冻结”要说准确:冻结的是引用集合,不是把正文另存成不可变副本。 读取时仍访问原消息行。这次修改没有实现内容哈希、签名或完整的历史版本存档,不能把它描述成一份不可篡改的授权证书。

3. 冻结出处,不等于冻结权限⌗

最容易过度设计的地方,是认为既然派发时可以读,以后就永远可以读。

实现没有这么做。

读取冻结引用时,查询仍调用 visibleThreadSql,以当前读取方的可见范围过滤消息。随后还会重新检查这些行是否属于可用的人类来源。

发生变化的东西新机制的处理
父线程出现新消息不自动进入已派出任务的来源集合
父线程切换会话代继续使用先前记录的消息 ID
子任务切换会话代引用随任务身份保留
来源线程的类别可见性变化读取时重新检查,不能凭旧引用穿透
框架显式向任务发消息重新捕获派发来源集合

这体现了两个不同的不变量:

  • 来源稳定性:任务不能因为父对话继续聊天,就自动获得另一批依据。
  • 访问有效性:曾经拿到消息引用,不等于永久有权读取它。

二者并不冲突。前者约束证据从哪里来,后者约束现在还能不能用。

同样,协议里的 role = user 也不等于人类原话。框架事件、组件输入、隔离消息可能占据这个角色。新路径在查询与 isMaxMessage 过滤中排除相应来源,防止机器送来的任务说明变成人类证词。

任务目标可以告诉模型做什么,但不能仅凭自己写着“用户要求”,就证明用户真的要求过。

4. 续派是一道边界,不是后台偷看⌗

异步任务当然需要接受新指令。答案不是永远锁死第一次快照,而是给更新一个明确入口。

src/dispatch/task-runner.ts 在向任务发送消息时重新捕获来源:空闲任务在新一轮执行前捕获;运行中的原生任务在投递安全点插话前捕获。同步续派路径也做同样处理。

数据库操作是替换该任务的引用数组,而不是无限追加一份“用户曾经允许过的事情大全”。

这和持续监听父线程的差别,在于因果关系:

父线程又说了一句话
    ≠ 旧任务自动得到新依据

框架显式向旧任务续派
    → 在这个边界重新选择来源

嵌套任务也沿用这条逻辑。子任务派出孙任务时,传递自己当时持有的来源,而不是让孙任务重新一路爬到顶层、搜最新聊天。代码通过读取父任务已有快照,再冻结到新任务上完成这件事。

但不要把它想象成已经完成了动作级授权。当前捕获逻辑选取的是近期人类消息集合,并非为某一个文件、某一个操作签发唯一许可。问题句是否构成指令、原话是否匹配、写入对象是否合适,仍是后续检查的责任。

此外,来源引用本身不能取消已经发生的副作用。撤销执行、停止任务,以及把新指令精确绑定到某次执行轮次,也不能由一个 ID 数组包办。

它解决的是证据集合随聊天漂移,不是所有授权问题。

5. 同一次改动里的另一面:复制不是改出处⌗

这次落地还修正了隔离占位消息的来源绑定。它与任务派发不是同一条执行路径,却暴露了同一种身份混淆。

工具结果被隔离后,模型看到的占位文字可能包含原消息 ID。消息被复制到新线程时,数据库行有了新 ID,但已经冻结的模型可见文字仍指向旧 ID。

如果检查器要求“占位里的指针必须等于当前行 ID”,合法副本就会被误判。反过来,为了通过检查而重写占位,又会改变冻结的回放文本。

新实现给隔离标记增加 messageId,校验时使用:

const messageId = mark.messageId ?? input.id

也就是明确区分:

身份回答的问题
当前消息行 ID这份副本存在哪里?
隔离标记里的消息 ID这段冻结占位最初指向哪里?

对缺少显式标记、也没有继承链的旧副本,兼容路径查找已有隔离工具行,并要求冻结文字与以候选 ID 生成的标准占位完全一致。不是从文字中随便抠出一个 ID 就信任它。

对应代码在 src/memory/messages.ts、src/memory/message-gate.ts 与 src/memory/quarantine.ts。

这里值得保留的原则是:对象搬了家,不代表它的出处也搬了家。 任务换代如此,消息复制也是如此。

6. 测试真正钉住了什么⌗

本文的验证依据是落地版本中的测试断言,不是另行实跑的测试报告。

src/write-routing/max-quote.pg.test.ts 把时间与来源边界写成了具体规格:

测试场景断言的结果
派发后父线程换代原请求仍可读取
派发后父线程新增请求旧任务仍只有派发时的依据
子任务继续派生任务继承冻结来源,不接纳注入的任务说明
来源只是一个问题不据此认定存在文档写入请求
来源类别变得不可见不返回该来源
显式续派,再让子任务换代使用更新后的来源引用
机器事件、组件与隔离消息混入不成为派发依据

另外,消息门测试接受指向正确源消息的占位副本,拒绝不匹配的来源标记;修复与压缩测试覆盖了没有继承链的旧副本,要求修复不误改它,换代不改写冻结的 modelContent 和 modelView。

这些测试的价值,不是证明“系统从此不会出错”,而是把两个容易混淆的概念拆开测试:现在的容器身份,与当时的证据身份。

7. 少追一层上下文,多留一条因果关系⌗

我的判断是,这个设计比运行时追索父对话更扎实,不在于它用了更多检查,而在于它少问了一个不稳定的问题。

旧问题是:“沿着关系链,现在能看到哪些用户话语?”

新问题是:“框架派发这个任务时,记录了哪些来源;这些来源现在是否仍可用?”

前者依赖会话此刻长什么样,后者依赖一次明确发生过的派发动作。

长任务、会话换代、嵌套派生,都会放大时间差。让模型在越来越长的上下文里猜“这句话当时是不是说给我的”,不是一种可靠的扩展方式。

该留在派发记录里的因果关系,就不要外包给模型临场理解。