后台任务不能拿后来的聊天,给自己补授权

题记:任务可以晚一点执行,但不能悄悄换掉“凭什么执行”的出处。
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. 少追一层上下文,多留一条因果关系⌗
我的判断是,这个设计比运行时追索父对话更扎实,不在于它用了更多检查,而在于它少问了一个不稳定的问题。
旧问题是:“沿着关系链,现在能看到哪些用户话语?”
新问题是:“框架派发这个任务时,记录了哪些来源;这些来源现在是否仍可用?”
前者依赖会话此刻长什么样,后者依赖一次明确发生过的派发动作。
长任务、会话换代、嵌套派生,都会放大时间差。让模型在越来越长的上下文里猜“这句话当时是不是说给我的”,不是一种可靠的扩展方式。
该留在派发记录里的因果关系,就不要外包给模型临场理解。