Skip to content

feat(session-continuity): survive client history compaction — root-anchor fallback + tail-anchored overlap; acct= in reuse log (opt-in, DEVIN_CONNECT_SESSION_REUSE) - #248

Open
warelik wants to merge 4 commits into
dwgx:masterfrom
warelik:pr/session-compaction-survival
Open

Conversation

@warelik

@warelik warelik commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

中文 TL;DR

部分客户端会 compact 自己的历史,会话复用时最近的配对窗口(pair window)变空或过短,会话认不出来、每轮都被当新会话,continuity 和 stable ModelConfig 在长程 agent 作业里悄悄失效。本 PR 给会话解析加 root-anchor fallback:窗口不可靠时改用对话根锚点匹配,并在出现歧义(两个活会话同根)时拒绝误认;overlap 计分改为尾锚点加权(紧凑化切的是头不是尾)。另在 reuse 日志补 acct= 字段,按账号排障不用再猜。仍在 DEVIN_CONNECT_SESSION_REUSE 门控内,关闭时行为逐字不变。

Что меняется

  • src/session-continuity.js:
    • при пустом/коротком pairWindow — fallback на dialog root anchor;
    • два живых состояния на одном корне → отказ (не угадываем, а отдаём новый session);
    • overlap score с опорой на tail-якорь (компакция отрезает голову цепи, хвост сохраняется);
    • форк-кандидаты тоже индексируются по rootKey — hijack через форк не проходит;
    • просроченные кандидаты в fallback-пути вычищаются по TTL, мёртвая сессия не воскресает.
  • src/handlers/chat.js: строка reuse-лога обогащена acct=.

Зачем

Клиентская компакция истории — нормальное поведение агентов на длинных прогонах. Без fallback сессия после компакии не резолвится, и именно тогда, когда continuity нужнее всего, он молча выключается.

Test plan

  • Тесты в test/session-continuity.test.js (+ test/chat-reuse-log.test.js): компакция клиентом истории (пустое/короткое окно пар) → резолв по root anchor; tail-anchored overlap различает свою/чужую сессию; два живых состояния на одном корне → отказ обоим; TTL-просрочка в fallback → новый session; acct= в reuse-логе.
  • Мутационная spec test/mutations/session-continuity-compaction-survival.json (3 мутации, все CAUGHT) + все spec репо: EXIT=0, anchor'ы ровно по разу.
  • Полный сьют: 3610 pass / 0 fail.

Безопасность

Гейт DEVIN_CONNECT_SESSION_REUSE default OFF: при выключенном гейте код пути не исполняется, поведение побайтово неизменно. Fallback срабатывает только при отсутствии свежих парных свидетельств и только на единственного живого кандидата.

W ARELIK and others added 3 commits August 6, 2026 17:58
… + tail-anchored overlap; acct= in reuse log

- overlapScore: document tail-anchored semantics (body already tail-anchored on
  master: contiguous run ending at the candidate's LAST hash).
- resolveSessionId: root-anchor fallback re-associates the committed session
  when a compacted history's pairs were rewritten (0/N survive byte-for-byte)
  but the first input turn survives verbatim. Fires only when NO incoming hash
  matched any stored index (pair evidence gone) — a divergent dialog whose
  prefix pair still hits the index must not be re-associated.
- commitAfterResponse: root-index forked states so the fallback's ambiguity rule
  (several live root candidates -> assign to none) can see same-opener forks and
  never hijack a fork into the original session.
- chat.js: DEVIN_CONNECT reuse log now carries acct=<account id> (env-token
  fallback).
- tests: compaction survival (root re-association + ambiguous no-hijack),
  tail-anchored overlap (prefix-only run scores 0; suffix match resolves),
  chat reuse-log acct= construction.
…ndex the new pair window; trailing newline
@dwgx

dwgx commented Aug 7, 2026

Copy link
Copy Markdown
Owner

评审:机制是对的,三道闸我逐条驱动过。请补一条 —— PR 正文承诺的覆盖范围比实际大一档。

我实跑过什么

head 9704634,worktree 隔离,未污染 master:

结果
npm run test:release 3610 pass / 0 fail(275 个文件,731 suites) —— 与你声明的逐字一致
session-continuity-compaction-survival.json 3/3 CAUGHT,含 over-reach 对照
全仓 spec anchor 153 条,零个不唯一
dependencies 仍为空
门关(DEVIN_CONNECT_SESSION_REUSE 未设) resolveSessionId 返回空 —— inert 成立

三道闸我是分别驱动的,不是读代码看出来的。 seen.size === 0 那道尤其关键:它让「前缀 pair 仍命中索引的分叉对话」走不到 root 回退,而你把理由写在注释里(pair evidence exists; it just scores 0)。overlapScore 改成锚在候选窗口的尾部也对 —— 压缩砍头不砍尾,所以保留的尾巴必然是已提交链的后缀,前缀-only 的 run 得 0 分。这两处是这个 PR 里最容易写错而你没写错的地方。

commitAfterResponse 给 fork 也建 root 索引那一段,注释说明了它存在的理由是让歧义规则能看见所有共享 root 的 state。这条是防劫持的支点,我特意攻了它。


M1(请补)— fork 场景下这个特性不生效,而正文没说

我构造了这个仓库最常见的形态:两个对话同一个 opener(agent 客户端普遍如此),各自分叉,然后其中一个压缩。

A: t1=614d3b6f  t2=614d3b6f  t3=614d3b6f
B: t1=614d3b6f  t2=0eb7492f  compacted=070be495

B 压缩后拿到 A 的 session : false      ← 没有劫持,歧义规则起作用了
A 保住自己的 session      : true
B 保住自己的 session      : false      ← 但 B 拿到的是全新 id

没有安全问题 —— 我原本怀疑的跨对话泄漏不成立,live.length === 1 那道闸拦住了。你的测试 :420「two live states sharing the root anchor with no pair evidence are ambiguous → a NEW id forms (no hijack)」和我实测一致。

但结果是:只要有第二个对话共享同一个 opener,压缩存活就失效,B 掉到新 session,也就是这个 PR 要修的那个连续性丢失。而 PR 正文写的是:

survive client history compaction

这句话在单对话下成立,在 fork 下不成立。你的测试已经钉住了这个边界,正文没有。 两条都不难,但我倾向后者:

  1. 收窄标题/正文,明确说「单对话压缩存活;多个对话共享 opener 时按歧义规则退化为新 session」。
  2. 让歧义可判定 —— 比如在 root 之外再带一个分叉判据(dialogAnchor 已经在 state 里了),使 B 能认回自己而不是退化。

我倾向 1。方案 2 是在给一个「不可判定的输入」造判据,而歧义规则本身是正确的保守选择 —— 宁可退化成新 session,也不能猜错把 B 接到 A 上。这和你在 #242 评审里接受的那个取舍是同一个方向。

M2(请核)— 与 #242session-continuity.js 上语义冲突

你和 #242 都改这个文件,两个对 master 都是 CLEAN,但那是各自对 base 算的,不说明两两之间。我这轮读出来的具体冲突点:

本 PR 的 root 回退在命中时会 state.pairWindow = newWindow 然后 indexState(...) —— 把 state 重新索引到压缩后历史的 pair hash 下。而 #242getSessionModelConfig / getSessionReasoningTrailresolveSessionId 之后调用,于是它看到的「pair 证据」已经是被本 PR 重写过的那一份。两个 PR 合并后互相改变对方的前提。

请你定一个合并顺序,并说明后合的那个要重做什么。我不打算替你选 —— 你比我清楚 #242 的 T1/T2 依赖哪一份 pairWindow。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants