@a9i5k4/dsh-auto-memory 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +13 -8
  2. package/README.zh-CN.md +13 -8
  3. package/docs/HANDBOOK.md +88 -52
  4. package/docs/USER-GUIDE.en.md +9 -9
  5. package/docs/USER-GUIDE.zh-CN.md +9 -9
  6. package/docs/screenshots/promo/promo-0-banner-v4.png +0 -0
  7. package/docs/screenshots/promo/promo-1b-auto-recall.png +0 -0
  8. package/lib/activation-host.js +6 -1
  9. package/lib/client.js +819 -272
  10. package/lib/context-host.js +7 -1
  11. package/lib/episodic-store.js +90 -16
  12. package/lib/fact-store.js +463 -41
  13. package/lib/hub-io.js +217 -0
  14. package/lib/index.js +224 -45
  15. package/lib/intent-clean-safe.js +1 -1
  16. package/lib/memory-hub.js +37 -5
  17. package/lib/note-status.js +9 -1
  18. package/lib/procedure-store.js +252 -31
  19. package/lib/procedure-switch.js +38 -0
  20. package/lib/python-sidecar-client.js +285 -8
  21. package/package.json +6 -2
  22. package/docs/internal/ACCEPT-35-LIVE.md +0 -143
  23. package/docs/internal/ACCEPTANCE-20260914.md +0 -90
  24. package/docs/internal/ARCH-REVIEW-BRIEF.md +0 -411
  25. package/docs/internal/ARCH-REVIEW-REQUEST.md +0 -201
  26. package/docs/internal/ARCH-REVIEW-ROUND2.md +0 -169
  27. package/docs/internal/ARCH-REVIEW-ROUND3.md +0 -206
  28. package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +0 -397
  29. package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +0 -351
  30. package/docs/internal/ART-DIRECTION-WIREFRAME.md +0 -191
  31. package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +0 -181
  32. package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +0 -314
  33. package/docs/internal/BATTLE-PLAN-20260917.md +0 -871
  34. package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +0 -192
  35. package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +0 -72
  36. package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +0 -131
  37. package/docs/internal/CUA-VISION-FIX-NOTES.md +0 -78
  38. package/docs/internal/DECISIONS-20260914-SESSION.md +0 -269
  39. package/docs/internal/DESIGN-OVERHAUL-PRE-RESEARCH.md +0 -292
  40. package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +0 -219
  41. package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +0 -132
  42. package/docs/internal/FEATURE-INVENTORY.md +0 -531
  43. package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +0 -13
  44. package/docs/internal/G-SERIES-EXECUTION-20260917.md +0 -248
  45. package/docs/internal/G3-DESIGN-20260918.md +0 -82
  46. package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +0 -92
  47. package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +0 -74
  48. package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +0 -352
  49. package/docs/internal/GPT-REVIEW-PROMPT.md +0 -216
  50. package/docs/internal/GROUP-DIGEST-SETUP.md +0 -62
  51. package/docs/internal/GROUP-LISTENER-SETUP.md +0 -49
  52. package/docs/internal/GROUP-WEBHOOK-SETUP.md +0 -93
  53. package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +0 -309
  54. package/docs/internal/HANDOFF-TO-ZCODE.md +0 -168
  55. package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +0 -120
  56. package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +0 -74
  57. package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +0 -175
  58. package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +0 -389
  59. package/docs/internal/ISSUE10-PLAN-20260919.md +0 -254
  60. package/docs/internal/ISSUE10B-FORENSICS-20260919.md +0 -468
  61. package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +0 -150
  62. package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +0 -114
  63. package/docs/internal/KICKOFF-P0.md +0 -254
  64. package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +0 -79
  65. package/docs/internal/MASTER-PLAN-3.0.md +0 -411
  66. package/docs/internal/MEMORY-GOVERNANCE-20260917.md +0 -309
  67. package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +0 -85
  68. package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +0 -222
  69. package/docs/internal/NEXT-VERSION-TODO.md +0 -95
  70. package/docs/internal/OFFICIAL-DISCUSSION-DRAFT.md +0 -80
  71. package/docs/internal/PENDING-FIXES-20260916.md +0 -289
  72. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +0 -705
  73. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +0 -649
  74. package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +0 -225
  75. package/docs/internal/PROGRESS-20260917.md +0 -93
  76. package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +0 -128
  77. package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +0 -163
  78. package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +0 -127
  79. package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +0 -140
  80. package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +0 -138
  81. package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +0 -218
  82. package/docs/internal/RAG-KARPATHY-PROGRAM.md +0 -229
  83. package/docs/internal/RELEASE-PROCESS.md +0 -99
  84. package/docs/internal/REPORT-P0-NIGHTLY.md +0 -212
  85. package/docs/internal/REPORT-P5-ACCEPTANCE.md +0 -31
  86. package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +0 -153
  87. package/docs/internal/RESUME-20260918.md +0 -171
  88. package/docs/internal/RESUME-20260919.md +0 -104
  89. package/docs/internal/REVIEW-WB-GRAPH-SELF.md +0 -81
  90. package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +0 -198
  91. package/docs/internal/ROADMAP-20260917-WEEK.md +0 -439
  92. package/docs/internal/ROADMAP.md +0 -106
  93. package/docs/internal/RUN-P0-NIGHTLY.md +0 -227
  94. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +0 -185
  95. package/docs/internal/S10-GAP-INVENTORY-20260917.md +0 -239
  96. package/docs/internal/S10-GAPS-PLAIN-20260917.md +0 -125
  97. package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +0 -360
  98. package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +0 -90
  99. package/docs/internal/SUBAGENT-REPORT-ROUTING-PRE-RESEARCH.md +0 -261
  100. package/docs/internal/T6-EXECUTION-20260920.md +0 -130
  101. package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +0 -146
  102. package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +0 -89
  103. package/docs/internal/THESIS-OUTLINE-20260918.md +0 -147
  104. package/docs/internal/THREE-LAYER-CONTRACT.md +0 -219
  105. package/docs/internal/TODO-BACKLOG.md +0 -263
  106. package/docs/internal/TODO-GRAPH.html +0 -715
  107. package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +0 -493
  108. package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +0 -710
  109. package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +0 -710
  110. package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +0 -703
  111. package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +0 -710
  112. package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +0 -715
  113. package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +0 -297
  114. package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +0 -104
  115. package/docs/internal/WB-FORMAT-CONVENTION.md +0 -112
  116. package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +0 -71
  117. package/docs/internal/WB-GRAPH-INTEGRATION-PLAN.md +0 -386
  118. package/docs/internal/WB-GRAPH-RESEARCH-BRIEF.md +0 -118
  119. package/docs/internal/WB-GRAPH-RESEARCH-EXTERNAL.md +0 -228
  120. package/docs/internal/WB-GRAPH-RESEARCH-LOCAL.md +0 -190
  121. package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +0 -56
  122. package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +0 -787
  123. package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +0 -112
  124. package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +0 -230
@@ -9,7 +9,19 @@
9
9
  * 错误 epoch/未知 requestId/重复或过期 response 全部计账丢弃,绝不注入上层。
10
10
  * - 四种身份不混用:requestId(transport)/observationId(M5)/activationId(M6)/syncId(index)。
11
11
  * - timeout/AbortSignal(cancel 通知)/latest-wins(由上层 M5 bridge 驱动)/crash recovery/circuit breaker。
12
- * 无 shell;无 HTTP;stdout 只进协议解析器;stderr 仅有界诊断。UTF-8 无 BOM。
12
+ * - ★ #107 看门狗:连续超时(心跳缺失)达阈值 ⇒ **kill 旧进程 + 下次请求重生新进程**。旧实现
13
+ * 超时只发 cancel 通知、从不 kill ⇒ 挂死的 worker 永远无人收尸,后续每个请求各自等到超时。
14
+ * stats 把「看门狗主动击杀」与「崩溃退出」分开记;熔断只由**终态成功**清零,段内进度帧
15
+ * (index_sync_begin / index_sync_page)的 ack 成功不清零 —— 否则每个 Segment 一次 begin 就把
16
+ * 累计失败抹平,峰值恒 1、阈值 3 永不达,熔断形同虚设。
17
+ * - ★ #108 stderr 可见:exit/fatal 分支把 stderr 尾部**原样**落一条 diag(同一次退出只记一条,
18
+ * 按行与字符双重截断防刷屏),并把截断后文本暴露为 stats.lastStderrTail /
19
+ * debugView().lastStderrTail;同时保留 stderrTailBytes 以区分「确实没输出」与「输出被截」。
20
+ * 无 shell;无 HTTP;stdout 只进协议解析器;stderr 有界诊断 + 有界 diag 环形缓冲(不再只留字节数)。
21
+ * ★ 影响面纪律:本模块是「JS 语义默认 / Python 发烧友可选」里**Python 那一侧**的传输层,不参与
22
+ * JS 语义路径;下述改动只影响 Python 档自身的行为,不改变 JS 端任何行为,也不让 python 侧的
23
+ * 存在或健康度成为 JS 语义生效的前提(未启动时零进程零 IO,调用方按需 lazy start)。
24
+ * UTF-8 无 BOM。
13
25
  */
14
26
  import { spawn } from 'node:child_process'
15
27
  import { randomBytes } from 'node:crypto'
@@ -22,6 +34,31 @@ import {
22
34
 
23
35
  const B = M7_TRANSPORT_BUDGET_V1
24
36
 
37
+ /**
38
+ * 看门狗/诊断策略(**客户端本地策略量**,刻意不放进 M7_TRANSPORT_BUDGET_V1)。
39
+ * 理由有二:①传输预算按契约冻结、变更须升协议版本,而「多久判挂死」是客户端自愈策略、不是 wire 契约;
40
+ * ②wire 文件同期另有 PR(#122/#124)在改,本任务不去碰它以避开写域冲突。
41
+ */
42
+ const SIDECAR_WATCHDOG_V1 = Object.freeze({
43
+ stallThreshold: 3, // 连续超时(心跳缺失)达此数 ⇒ 判定挂死,看门狗击杀旧进程
44
+ stderrTailChars: 4096, // stderr 环形尾部保留长度(既有语义,原为字面量 4096)
45
+ diagRingCapacity: 16, // diag 环形缓冲条数(超出丢最旧)
46
+ diagTailLines: 8, // 单条 diag 最多保留的 stderr 行数(防刷屏)
47
+ diagTailChars: 1200, // 单条 diag 最多保留的 stderr 字符数
48
+ })
49
+
50
+ /** 退出归因优先级:高优先级不被低优先级覆盖。 */
51
+ const EXIT_KIND_RANK_V1 = Object.freeze({ crash: 0, restart: 1, dispose: 1, fatal: 2, watchdog: 3 })
52
+
53
+ /**
54
+ * ★ #107 熔断清零口径:只有「终态成功」才把累计失败清零。
55
+ * index_sync_begin / index_sync_page 是**段内进度帧**(每个 Segment 都会 begin 一次),它们的 ack
56
+ * 成功只证明 worker 读到并回了一行,不能证明它健康。旧实现无差别清零 ⇒ 每个 Segment 的 begin 一经
57
+ * 成功就把累计失败抹平,峰值恒 1、阈值 3(breakerFailureThreshold)永不达,熔断形同虚设。
58
+ * health / context_push / index_sync_commit 才是各自工作单元的终态应答。
59
+ */
60
+ const TERMINAL_SUCCESS_TYPES_V1 = new Set(['health', 'context_push', 'index_sync_commit'])
61
+
25
62
  /** 捆绑 fake worker 的默认绝对路径(python/worker_v1.py)。 */
26
63
  export function defaultWorkerScriptPathPre() {
27
64
  try {
@@ -53,6 +90,19 @@ export function createPythonSidecarClientPre(opts = {}) {
53
90
  const seenActivationIdSet = new Set()
54
91
  let writeChain = Promise.resolve()
55
92
  let lastSentFrame = null
93
+ // ---- #107 看门狗 / #108 stderr 可见:状态 ----
94
+ let genSeq = 0 // 世系序号发生器
95
+ let generation = 0 // 当前世系号(0 = 从未启动过)
96
+ let consecutiveTimeouts = 0 // 连续超时(心跳缺失)计数
97
+ let halfOpenPending = false // 熔断冷却到期后待用 health() 探活
98
+ let respawnPending = false // 看门狗击杀后待重生(下次 spawn 计一次 watchdog.respawns)
99
+ let lastStderr = { tail: '', bytes: 0, truncated: false, at: 0, generation: 0, totalLines: 0, keptLines: 0 }
100
+ let lastExitDiagKey = '' // 「同一次退出只记一条」去重键 = 世系号 + 退出种类
101
+ let lastExitDiagRef = null // 该条 diag 记录引用(供 exit 之后迟到的 stderr 就地刷新)
102
+ let lastExitGen = 0 // 最近一次退出的世系号
103
+ const genOfProc = new WeakMap() // ChildProcess -> 世系号
104
+ const exitKindByGen = new Map() // 世系号 -> 退出种类(crash/restart/dispose/fatal/watchdog)
105
+ const diagRing = [] // 有界 diag 环形缓冲(给人看的诊断,含中文归因)
56
106
  const breaker = { consecutiveFailures: 0, openUntil: 0 }
57
107
  const stats = {
58
108
  starts: 0, exits: 0, framesIn: 0, requests: 0, succeeded: 0, activationsReceived: 0,
@@ -60,6 +110,17 @@ export function createPythonSidecarClientPre(opts = {}) {
60
110
  dropped: { badJson: 0, badEnvelope: 0, staleEpoch: 0, unknownRequest: 0, typeMismatch: 0, duplicateActivation: 0 },
61
111
  failed: { timeout: 0, crashed: 0, aborted: 0, circuitOpen: 0, unavailable: 0, backpressure: 0, protocol: 0, workerError: 0, disposed: 0 },
62
112
  lastExit: null, lastFatal: null,
113
+ // ★ #107 看门狗:把「主动重启」与「崩溃退出」分开记,不再混在一个 exits 计数里。
114
+ watchdog: { kills: 0, respawns: 0, lastReason: null, lastAt: 0, lastDetail: null },
115
+ exitKinds: { crash: 0, restart: 0, dispose: 0, fatal: 0, watchdog: 0, spawnError: 0 },
116
+ breakerProbes: { attempted: 0, ok: 0, failed: 0 },
117
+ lastExitNote: null,
118
+ // ★ #108 stderr:lastStderrTail 是**截断后可读文本**(不再只有字节数);
119
+ // stderrTailBytes 仍在 debugView 上,用来区分「没输出」与「输出被截」。
120
+ lastStderrTail: '',
121
+ lastStderrTailBytes: 0,
122
+ lastStderrTailTruncated: false,
123
+ lastStderrTailAt: 0,
63
124
  }
64
125
 
65
126
  function resolveOpt(k) { return opt(k, null) }
@@ -75,6 +136,157 @@ export function createPythonSidecarClientPre(opts = {}) {
75
136
 
76
137
  function breakerOpen() { return Date.now() < breaker.openUntil }
77
138
 
139
+ // ============ #107 看门狗 / #108 stderr 可见:诊断与自愈 ============
140
+ // 说明:以下全部只服务 Python 侧车自身的自愈与可观测性,不参与 JS 语义路径。
141
+
142
+ /** 有界 diag 环形缓冲:落一条**给人看**的中文诊断;opts.onDiag 在场时同步外发一份。 */
143
+ function pushDiag(level, text) {
144
+ const rec = { at: Date.now(), level, sidecar: 'python-sidecar-pre', generation, message: String(text) }
145
+ diagRing.push(rec)
146
+ while (diagRing.length > Math.max(1, Number(opt('diagRingCapacity', SIDECAR_WATCHDOG_V1.diagRingCapacity)))) diagRing.shift()
147
+ const sink = resolveOpt('onDiag')
148
+ if (typeof sink === 'function') { try { sink({ at: rec.at, level: rec.level, generation, message: rec.message }) } catch (_) {} }
149
+ return rec
150
+ }
151
+
152
+ /**
153
+ * 把 stderr 尾部整理成可读文本:保留尾 N 行(默认 8)且不超过 M 字符(默认 1200)。
154
+ * 被截断时在正文前留一行说明 —— 避免读者误以为「这就是全部输出」。
155
+ */
156
+ function tailForDiag(raw) {
157
+ const NL = String.fromCharCode(10)
158
+ const CR = String.fromCharCode(13)
159
+ const src = String(raw == null ? '' : raw)
160
+ const bytes = src.length
161
+ // 刻意不用正则字面量:本仓所有 .js 均为 CRLF,避免任何转义歧义。
162
+ const all = src.split(NL).map((l) => (l.endsWith(CR) ? l.slice(0, -1) : l))
163
+ while (all.length && all[all.length - 1] === '') all.pop()
164
+ const maxLines = Math.max(1, Number(opt('diagTailLines', SIDECAR_WATCHDOG_V1.diagTailLines)))
165
+ const maxChars = Math.max(1, Number(opt('diagTailChars', SIDECAR_WATCHDOG_V1.diagTailChars)))
166
+ const kept = all.slice(-maxLines)
167
+ let truncated = kept.length < all.length
168
+ let text = kept.join(NL)
169
+ if (text.length > maxChars) { text = text.slice(-maxChars); truncated = true }
170
+ const head = truncated ? '…[已按行/字符截断,下面不是全部输出]…' + NL : ''
171
+ return { text: head + text, truncated, keptLines: kept.length, totalLines: all.length, bytes }
172
+ }
173
+
174
+ /** 退出归因中文说明(回答「哪一侧车、哪一代、怎么退的、退出码多少」)。 */
175
+ function humanExitNote(gen, kind, code, signalName) {
176
+ const why = kind === 'watchdog' ? '被看门狗主动击杀(连续超时=心跳缺失达阈值,判定挂死)'
177
+ : kind === 'fatal' ? '因协议致命错误被就地击杀(帧纪律违规)'
178
+ : kind === 'restart' ? '被 restart() 主动重启(刻意换代,不是故障)'
179
+ : kind === 'dispose' ? '随 dispose() 收尾关闭(刻意关闭,不是故障)'
180
+ : '自行退出(非看门狗击杀)——即「崩溃退出」'
181
+ const c = (code === null || code === undefined) ? 'null' : String(code)
182
+ const s = signalName ? String(signalName) : 'null'
183
+ return '第 ' + gen + ' 代 Python 语义侧车' + why + ':退出码 code=' + c + ',signal=' + s
184
+ }
185
+
186
+ /** 退出归因写入(优先级 watchdog > fatal > dispose/restart > crash;低优先级不覆盖高优先级)。 */
187
+ function markExitKind(gen, kind) {
188
+ if (!gen) return
189
+ const cur = exitKindByGen.get(gen) || 'crash'
190
+ if ((EXIT_KIND_RANK_V1[kind] || 0) < (EXIT_KIND_RANK_V1[cur] || 0)) return
191
+ exitKindByGen.set(gen, kind)
192
+ while (exitKindByGen.size > 64) exitKindByGen.delete(exitKindByGen.keys().next().value)
193
+ }
194
+
195
+ /**
196
+ * ★ #108 关键修复:把 stderr 尾部**原样**落一条 diag。
197
+ * 旧实现只把 4KB 尾部收进 `stderrTail` 变量、对外仅暴露 `stderrTailBytes` 一个整数,
198
+ * **内容没有任何读者** ⇒ Python traceback 永远看不到,用户只能看到「字节数非 0」。
199
+ * 同一次退出只记一条:以「世系号 + 退出种类」为键,命中则**就地刷新**(退出之后 stdio 往往
200
+ * 还在排空,close 晚于 exit、尾字节会迟到;就地刷新既补全内容又不新开第二条)。
201
+ */
202
+ function logExitDiag(gen, kind, reasonText) {
203
+ const t = tailForDiag(stderrTail)
204
+ lastStderr = { tail: t.text, bytes: t.bytes, truncated: t.truncated, at: Date.now(), generation: gen, totalLines: t.totalLines, keptLines: t.keptLines }
205
+ stats.lastStderrTail = t.text
206
+ stats.lastStderrTailBytes = t.bytes
207
+ stats.lastStderrTailTruncated = t.truncated
208
+ stats.lastStderrTailAt = lastStderr.at
209
+ const note = t.bytes === 0
210
+ ? 'stderr 零输出(确实没打印,不是被截断)'
211
+ : (t.truncated
212
+ ? 'stderr 尾部累计 ' + t.bytes + ' 字符 / ' + t.totalLines + ' 行,此处只保留最后 ' + t.keptLines + ' 行共 ' + t.text.length + ' 字符(已按行与字符双重截断,防刷屏)'
213
+ : 'stderr 尾部累计 ' + t.bytes + ' 字符 / ' + t.totalLines + ' 行,已全部保留')
214
+ const NL = String.fromCharCode(10)
215
+ const body = String(reasonText) + ';' + note + (t.text ? NL + '—— stderr 原文 ——' + NL + t.text : '')
216
+ const key = gen + ':' + kind
217
+ const level = (kind === 'crash' || kind === 'watchdog' || kind === 'fatal') ? 'error' : 'info'
218
+ if (lastExitDiagKey === key && lastExitDiagRef) {
219
+ lastExitDiagRef.message = body
220
+ lastExitDiagRef.at = lastStderr.at
221
+ lastExitDiagRef.level = level
222
+ return lastExitDiagRef
223
+ }
224
+ lastExitDiagKey = key
225
+ lastExitGen = gen
226
+ lastExitDiagRef = pushDiag(level, body)
227
+ return lastExitDiagRef
228
+ }
229
+
230
+ /**
231
+ * ★ #107 看门狗:判定侧车挂死 ⇒ **kill 旧进程 + 下次请求重生新进程**。
232
+ * 旧实现在超时分支只 `notify('cancel')`、从不 kill ⇒ 挂死的 worker 永远无人收尸,后续每个
233
+ * 请求各自等到自己的超时、再各自发一条无用的 cancel;对用户的表现是「Python 档语义静默失效」。
234
+ * 刻意不在此处立即 spawn:避免在熔断打开期制造进程(既有套件断言「打开期零 spawn」);
235
+ * 重建交给下一次 ensureStarted() —— 那里必然带新 epoch,index 重同步也随之触发。
236
+ */
237
+ function disarmHungChild(trigger, detail) {
238
+ const proc = child
239
+ consecutiveTimeouts = 0
240
+ if (!proc) return
241
+ markExitKind(generation, 'watchdog')
242
+ child = null
243
+ epoch = null
244
+ buffer = Buffer.alloc(0)
245
+ respawnPending = true
246
+ stats.watchdog.kills++
247
+ stats.watchdog.lastReason = trigger
248
+ stats.watchdog.lastAt = Date.now()
249
+ stats.watchdog.lastDetail = detail || null
250
+ const thr = Math.max(1, Number(opt('watchdogStallThreshold', SIDECAR_WATCHDOG_V1.stallThreshold)))
251
+ pushDiag('error', '第 ' + generation + ' 代 Python 语义侧车连续 ' + thr + ' 次心跳缺失(' + trigger + ')⇒ 看门狗主动击杀旧进程;下次请求将重生新进程(新 epoch),未经确认的旧 in-flight 请求按 crashed 结算。')
252
+ logExitDiag(generation, 'watchdog', humanExitNote(generation, 'watchdog', null, null))
253
+ try { proc.stdin.destroy() } catch (_) {}
254
+ try { proc.kill() } catch (_) {}
255
+ rejectAll('crashed')
256
+ }
257
+
258
+ /** 心跳缺失证据:只把「超时」算挂死(abort 是调用方主动取消,不构成挂死证据)。 */
259
+ function noteStall(type, requestId, timeoutMs) {
260
+ consecutiveTimeouts++
261
+ const thr = Math.max(1, Number(opt('watchdogStallThreshold', SIDECAR_WATCHDOG_V1.stallThreshold)))
262
+ if (consecutiveTimeouts < thr) return
263
+ disarmHungChild('连续超时', { type: String(type), requestId: String(requestId), timeoutMs: Number(timeoutMs), consecutiveTimeouts })
264
+ }
265
+
266
+ /**
267
+ * ★ #107 熔断半开探测:冷却到期后不放行盲发,先用 health()(有界)自证 worker 活着再放行。
268
+ * 这是 health() 在仓内的**唯一真实调用点**。type==='health' 不入此分支 ⇒ 不会递归,
269
+ * 也不会把探针自身的失败再算作一次新的挂死证据。
270
+ */
271
+ async function probeThenSend(type, payload, rOpts) {
272
+ halfOpenPending = false
273
+ stats.breakerProbes.attempted++
274
+ const probeTimeout = Math.min(Math.max(1, Number(opt('requestTimeoutMs', B.requestTimeoutMs))), 2000)
275
+ const probe = await request('health', {}, { timeoutMs: probeTimeout })
276
+ if (probe && probe.ok) {
277
+ stats.breakerProbes.ok++
278
+ breaker.consecutiveFailures = 0
279
+ consecutiveTimeouts = 0
280
+ return request(type, payload, rOpts)
281
+ }
282
+ stats.breakerProbes.failed++
283
+ breaker.openUntil = Date.now() + Number(opt('breakerCooldownMs', B.breakerCooldownMs))
284
+ halfOpenPending = true
285
+ stats.failed.circuitOpen++
286
+ pushDiag('error', '熔断半开探测未通过:Python 语义侧车 health 探针返回 ' + String((probe && probe.code) || 'unknown') + ' ⇒ 重新打开熔断 ' + Number(opt('breakerCooldownMs', B.breakerCooldownMs)) + 'ms;本次请求按 circuit-open 返回。')
287
+ return { ok: false, code: 'circuit-open', retryInMs: breaker.openUntil - Date.now() }
288
+ }
289
+
78
290
  /** lazy start:no-shell spawn;仅由显式启用的调用路径触达。 */
79
291
  function ensureStarted() {
80
292
  if (disposed) return { ok: false, code: 'disposed' }
@@ -106,9 +318,16 @@ export function createPythonSidecarClientPre(opts = {}) {
106
318
  }
107
319
  child = proc
108
320
  stats.starts++
321
+ generation = ++genSeq
322
+ genOfProc.set(proc, generation)
323
+ exitKindByGen.set(generation, 'crash')
324
+ stderrTail = '' // ★ #108:stderr 尾部按**世系**隔离,避免把上代 traceback 误挂到本代
325
+ consecutiveTimeouts = 0
326
+ if (respawnPending) { stats.watchdog.respawns++; respawnPending = false }
109
327
  proc.stdout.on('data', (chunk) => { try { feed(chunk) } catch (_) { fatal('protocol') } })
110
328
  proc.stderr.on('data', (chunk) => {
111
- stderrTail = (stderrTail + chunk.toString('utf8')).slice(-4096)
329
+ stderrTail = (stderrTail + chunk.toString('utf8'))
330
+ .slice(-Math.max(256, Number(opt('stderrTailChars', SIDECAR_WATCHDOG_V1.stderrTailChars))))
112
331
  })
113
332
  proc.on('error', () => {
114
333
  // spawn 失败(ENOENT 等):结构化失败,不计崩溃重启
@@ -120,12 +339,19 @@ export function createPythonSidecarClientPre(opts = {}) {
120
339
  noteFailure('unavailable')
121
340
  })
122
341
  proc.on('exit', (code, signalName) => {
342
+ const gen = genOfProc.get(proc) || 0
343
+ const kind = exitKindByGen.get(gen) || 'crash'
344
+ // ★ #107:无论是否当前世系都先记账 —— 「哪一侧车、第几代、怎么退的、退出码多少」
345
+ // stats.exits 仍是总退出数;active 的归因细分在 stats.exitKinds。
346
+ stats.exits++
347
+ stats.exitKinds[kind] = (stats.exitKinds[kind] || 0) + 1
348
+ stats.lastExit = { code, signal: signalName, generation: gen, kind }
349
+ stats.lastExitNote = humanExitNote(gen, kind, code, signalName)
350
+ logExitDiag(gen, kind, stats.lastExitNote)
123
351
  if (child !== proc) return
124
352
  child = null
125
353
  epoch = null
126
354
  buffer = Buffer.alloc(0)
127
- stats.exits++
128
- stats.lastExit = { code, signal: signalName }
129
355
  rejectAll('crashed')
130
356
  })
131
357
  return { ok: true }
@@ -133,13 +359,21 @@ export function createPythonSidecarClientPre(opts = {}) {
133
359
 
134
360
  function rejectAll(code) {
135
361
  for (const [, entry] of pending) settle(entry, { ok: false, code })
136
- noteFailure(code)
362
+ // ★ #107:不再在循环外额外 noteFailure(code) —— settle() 已按每个失败请求各计一次,
363
+ // 旧实现在此重复计数 ⇒ 崩溃时挂 K 个请求会被算成 K+1 次失败,熔断阈值提前触发(假熔断)。
137
364
  }
138
365
 
139
366
  function fatal(kind) {
140
367
  stats.lastFatal = kind
141
368
  buffer = Buffer.alloc(0) // 丢弃残留半帧,防止污染重生进程的解析流
142
- if (child) { try { child.stdin.destroy() } catch (_) {} try { child.kill() } catch (_) {} }
369
+ // ★ #108:致命分支同样把 stderr 尾部原样落一条 diag;与随后 exit 同键(世系号+fatal)
370
+ // ⇒ 同一次退出只记一条。
371
+ logExitDiag(generation, 'fatal', humanExitNote(generation, 'fatal', null, null) + ' 触发原因=' + String(kind))
372
+ if (child) {
373
+ markExitKind(generation, 'fatal')
374
+ try { child.stdin.destroy() } catch (_) {}
375
+ try { child.kill() } catch (_) {}
376
+ }
143
377
  rejectAll('protocol')
144
378
  }
145
379
 
@@ -208,7 +442,17 @@ export function createPythonSidecarClientPre(opts = {}) {
208
442
  if (entry.timer) clearTimeout(entry.timer)
209
443
  if (entry.onAbort) { try { entry.signal.removeEventListener('abort', entry.onAbort) } catch (_) {} }
210
444
  pending.delete(entry.requestId)
211
- if (result.ok) { stats.succeeded++; breaker.consecutiveFailures = 0 }
445
+ if (result.ok) {
446
+ stats.succeeded++
447
+ // ★ #107 熔断清零口径:只有「终态成功」清零累计失败;段内进度帧(index_sync_begin /
448
+ // index_sync_page)的 ack 成功**不清零** —— 否则每个 Segment 一次 begin 就把累计失败
449
+ // 抹平,峰值恒 1、阈值 3 永不达,熔断形同虚设。
450
+ if (TERMINAL_SUCCESS_TYPES_V1.has(entry.type)) {
451
+ breaker.consecutiveFailures = 0
452
+ consecutiveTimeouts = 0
453
+ halfOpenPending = false
454
+ }
455
+ }
212
456
  else {
213
457
  const bucket = stats.failed[result.code]
214
458
  if (bucket === undefined) stats.failed.protocol++
@@ -250,6 +494,19 @@ export function createPythonSidecarClientPre(opts = {}) {
250
494
  stats.failed.circuitOpen++
251
495
  return Promise.resolve({ ok: false, code: 'circuit-open', retryInMs: breaker.openUntil - Date.now() })
252
496
  }
497
+ // ★ #107 熔断半开探测:冷却到期后不放行盲发,先用 health()(有界)自证 worker 活着再放行。
498
+ // 这是 health() 在仓内的**唯一真实调用点** —— 旧实现 health() 全仓零调用,文件里那句
499
+ // 「health 探针(breaker half-open 用;有界响应)」是空头承诺。type==='health' 不入此分支
500
+ // ⇒ 不会递归,也不会把探针本身的失败再算作一次挂死证据。
501
+ if (halfOpenPending && type !== 'health') return probeThenSend(type, payload, rOpts)
502
+ return sendNow(type, payload, rOpts)
503
+ }
504
+
505
+ /**
506
+ * 真正的建帧 + 入队。与旧 request() 同体,刻意保持「调用后同步可见 _lastFrameForTest()」
507
+ * 的既有契约(既有套件靠它在 await 之前做帧级断言)。
508
+ */
509
+ function sendNow(type, payload, rOpts = {}) {
253
510
  const started = ensureStarted()
254
511
  if (!started.ok) {
255
512
  stats.failed[started.code] = (stats.failed[started.code] || 0) + 1
@@ -262,7 +519,7 @@ export function createPythonSidecarClientPre(opts = {}) {
262
519
  const mf = makeRequestFramePre({ type, payload, requestId, workerEpoch: epoch, sentAt })
263
520
  if (!mf.ok) { stats.failed.protocol++; return Promise.resolve({ ok: false, code: 'protocol', reason: mf.reason }) }
264
521
  return new Promise((resolve) => {
265
- const entry = { requestId, expectedType: RESPONSE_TYPE_FOR_V1[type], resolve, settled: false, timer: null, signal: rOpts.signal || null, onAbort: null }
522
+ const entry = { requestId, type, expectedType: RESPONSE_TYPE_FOR_V1[type], resolve, settled: false, timer: null, signal: rOpts.signal || null, onAbort: null }
266
523
  pending.set(requestId, entry)
267
524
  stats.requests++
268
525
  const written = writeFrame(mf.frame)
@@ -271,6 +528,9 @@ export function createPythonSidecarClientPre(opts = {}) {
271
528
  entry.timer = setTimeout(() => {
272
529
  settle(entry, { ok: false, code: 'timeout', timeoutMs })
273
530
  notify('cancel', { requestId })
531
+ // ★ #107:旧实现到此为止(只发 cancel,不 kill)⇒ 挂死的 worker 无人收尸。
532
+ // 这里补上心跳缺失证据的累计与看门狗收尸(达阈值才动,避免偶发慢帧被误杀)。
533
+ noteStall(type, requestId, timeoutMs)
274
534
  }, timeoutMs)
275
535
  if (entry.signal) {
276
536
  if (entry.signal.aborted) {
@@ -309,6 +569,10 @@ export function createPythonSidecarClientPre(opts = {}) {
309
569
  // 旧代残尾会污染新 worker 首帧 ⇒ 该行 badJson ⇒ 请求白等到超时)。
310
570
  buffer = Buffer.alloc(0)
311
571
  stats.restarts++
572
+ markExitKind(generation, 'restart')
573
+ breaker.consecutiveFailures = 0
574
+ consecutiveTimeouts = 0
575
+ pushDiag('info', 'restart() 主动重启 Python 语义侧车:旧进程已击杀、新 epoch 待下次请求重生(原因:' + String(reason === undefined || reason === null ? '未给' : reason) + ')。')
312
576
  void reason
313
577
  }
314
578
 
@@ -322,7 +586,18 @@ export function createPythonSidecarClientPre(opts = {}) {
322
586
  epoch: epoch ? epoch.slice(0, 12) + '…' : null,
323
587
  pending: pending.size,
324
588
  breaker: { open: breakerOpen(), consecutiveFailures: breaker.consecutiveFailures, cooldownMs: Number(opt('breakerCooldownMs', B.breakerCooldownMs)) },
589
+ generation,
590
+ // ★ #108:stderrTailBytes 保留原语义(当前世系累计保留的尾部长度)——0 表示确实没输出,
591
+ // 非 0 且 lastStderrTailTruncated=true 表示有输出但被截断。文本本体见 lastStderrTail。
325
592
  stderrTailBytes: stderrTail.length,
593
+ lastStderrTail: lastStderr.tail,
594
+ lastStderrTailBytes: lastStderr.bytes,
595
+ lastStderrTailTruncated: lastStderr.truncated,
596
+ lastStderrTailAt: lastStderr.at,
597
+ consecutiveTimeouts,
598
+ halfOpenProbePending: halfOpenPending,
599
+ watchdog: JSON.parse(JSON.stringify(stats.watchdog)),
600
+ recentDiag: diagRing.slice(-4).map((d) => ({ at: d.at, level: d.level, message: d.message })),
326
601
  stats: JSON.parse(JSON.stringify(stats)),
327
602
  }
328
603
  }
@@ -330,6 +605,7 @@ export function createPythonSidecarClientPre(opts = {}) {
330
605
  function dispose(reason) {
331
606
  if (disposed) return
332
607
  disposed = true
608
+ markExitKind(generation, 'dispose')
333
609
  if (child) { try { child.kill() } catch (_) {} }
334
610
  child = null
335
611
  epoch = null
@@ -348,5 +624,6 @@ export function createPythonSidecarClientPre(opts = {}) {
348
624
  onActivation(handler) { activationHandlers.add(handler); return () => activationHandlers.delete(handler) },
349
625
  _statsForTest: stats,
350
626
  _pendingForTest: pending,
627
+ _diagRingForTest: diagRing,
351
628
  }
352
629
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@a9i5k4/dsh-auto-memory",
3
3
  "description": "Proactive associative memory for DSH: zero-prompt recall injected before the model speaks, three-layer auto-consolidation, skill crystallization, and Astra-style context management - handoff ledgers, PLAN whiteboard, water-level sensing. Local-first, model-agnostic, zero deps. 主动联想记忆+Astra 式上下文管理:自动唤回/自动沉淀/技能固化/交接账本与白板跨窗口续命/水位感知。",
4
- "version": "3.0.1",
4
+ "version": "3.1.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -15,7 +15,11 @@
15
15
  "docs",
16
16
  "cordis.patch.yml",
17
17
  "!python/bench",
18
- "!python/__pycache__"
18
+ "!python/__pycache__",
19
+ "!docs/internal",
20
+ "!docs/**/*.bak",
21
+ "!docs/**/*.bak-*",
22
+ "!**/*.mjs.bak-*"
19
23
  ],
20
24
  "dsh": {
21
25
  "bundle": {
@@ -1,143 +0,0 @@
1
- # #35 真机验收方案(停旧回合 → 仪式 → 真判据 → 新窗口)
2
-
3
- > 目标:把 #35 的修复从**夹具级**(`smoke-test-autocont-host-pre.mjs` 95 断言)升级为**真机级**。
4
- > 本文件给**新会话**执行;执行者不需要读原对话。改配置前先备份(用户级硬规则)。
5
-
6
- ## 0. 为什么必须换一个会话做
7
-
8
- - 验收的**核心那一档**要求在「回合正在运行时点同意」——那会**终止当前回合**。
9
- 拿重要对话当靶子会打断它,所以:**另开一个新会话当"旧会话"被接续**,本对话不受影响。
10
- - 新会话要够长、有实质内容(交接材料才有东西可带);空会话测不出材料质量。
11
-
12
- ## 1. 前置条件(**2026-09-14 解耦后已更新:只需开一个开关**)
13
-
14
- > 历史注记:解耦前 `handoffEnabled=false` 会让 `checkWaterLevel` 提前返回 → `waterLevelModelKnown` 不写 → fail-closed 闸永不 arm,所以当时**必须同时开两个开关**。那处耦合已切除(`lib/index.js` 的 `checkWaterLevel` 早退、`armAutoContinue` 早退、`autoContinueState.enabled` 二次与运算、`buildContinueCarry` 的 `handoff disabled` 早退,共 4 处)。
15
-
16
- 这台机器的现行配置(`~/.dsh/dsh-auto-memory-pre.json`):
17
-
18
- | 键 | 现值 | 现在的要求 |
19
- |---|---|---|
20
- | `autoContinueEnabled` | **false** | **必须临时改 true**——它是接续的唯一资格开关 |
21
- | `handoffEnabled` | false | **可保持 false**,此时走「最小转写载体」;也可改 true 走完整两层载体(见步骤 1 的 A/B 跑法) |
22
- | `autoContinueThreshold` | 0.75 | 正常会话很难自然达阈值,验收要临时降 |
23
-
24
- **结论:只开 `autoContinueEnabled` 即可跑验收。** 而且**建议第一轮就保持白板关闭**——那条路径是这次解耦才第一次可用的(此前直接 hard-fail),顺带把解耦也验了。
25
-
26
- ## 2. 步骤
27
-
28
- ### 步骤 0 · 备份(必做)
29
- ```pwsh
30
- Copy-Item "$env:USERPROFILE\.dsh\dsh-auto-memory-pre.json" "$env:USERPROFILE\.dsh\dsh-auto-memory-pre.json.bak-accept35" -Force
31
- ```
32
-
33
- ### 步骤 1 · 临时开开关 + 降阈值(**A/B 两轮**)
34
-
35
- **A 轮(先跑,白板关闭 —— 验解耦后的最小载体)**:
36
- ```json
37
- "autoContinueEnabled": true,
38
- "autoContinueThreshold": 0.05
39
- ```
40
- 保持 `handoffEnabled: false` 不动。此轮新会话应拿到**转写包 + 近期线程**,且材料里带一句自述「未启用白板/账本」;仪式步骤会被**跳过**(日志 `reason:'handoff-disabled'`),这是**预期**,不是失败。
41
-
42
- **B 轮(A 通过后再跑,白板打开 —— 验完整两层载体)**:
43
- 再把 `"handoffEnabled": true` 打开,重跑一次,观察步骤 ②③ 的仪式链路。
44
-
45
- > 为什么降的是 `autoContinueThreshold` 而不是 `waterLevelWindowTokens`:改窗口会让**水位测量本身**失真,连带 advisory 与自动账本写入都变形;降阈值只动"什么时候弹卡"。
46
- > 每轮改完**重启 dsh web**(注入面与开关在启动时读)。**注意冷却**:`autoContinueCooldownMinutes=30`,触发过一次后 30 分钟内不再弹——两轮之间要么等,要么把冷却临时调小。
47
-
48
- ### 步骤 2 · 造场景
49
- 在**新会话**里正常跑一两轮(有工具调用、有实质产出)。
50
- - 注意:**首轮 pre-step 拿不到模型信息**(`request/header` 还没写),按 #33 的 fail-closed 设计,首轮的按比例 arm 会被拦,**推迟到该轮 turn-stopping**。所以卡片应在**第一轮结束后**出现,而不是开局。
51
- - 若 10 分钟不弹卡:先查日志有没有 `auto-continue armed`,没有就回头查开关与 `modelKnown`(见 §4 排查)。
52
-
53
- ### 步骤 3 · 点「同意接续」并采日志
54
- 两种点法,验证深度不同——**建议先 A 后 B**:
55
-
56
- - **A 档(先做,安全)**:**空闲时点**。验通 ①→④ 全链路与节拍;`cancel` 对空转回合是空操作。
57
- - **B 档(核心,代价已知)**:**让一个回合跑着(多步工具调用/长输出),在运行中点同意**。这才是 issue 原现场,也是唯一能验「回合活跃时不再并行」的做法。**代价:该回合会被终止,未完成的回答落 `interrupted`。**
58
-
59
- 采集(改完配置重启后):
60
- ```pwsh
61
- Get-Content "$env:USERPROFILE\.dsh\dsh-auto-memory-pre-diagnose.log" -Tail 200 |
62
- Select-String 'auto-continue|ritual|prev-session|stopped|waited'
63
- ```
64
-
65
- ## 3. 判据(缺一不算过;第 3 条仅 B 轮适用,A 轮看 3′)
66
-
67
- | # | 期望日志 | 含义 |
68
- |---|---|---|
69
- | 1 | `auto-continue armed: … awaitIdle=true …` | 闸放行了(能验到这条本身就说明 #33 的 fail-closed 没误杀) |
70
- | 2 | **`auto-continue: stopped old turn sid=…`** | **① 新链路真的执行了**;若旧回合在跑,旧窗口应显示被打断 |
71
- | 3 | **`host refresh ritual: … waited=updated`**(**仅 B 轮/白板开时适用**) | **②③ 因果闭合判据通过**(若显示 `waited=stamp-fallback` → 说明 `sc.inspect` 不可用、退到弱判据,**要当作半通过并单独记**) |
72
- | 3′ | **A 轮(白板关)替代判据**:日志出现仪式的 `reason:'handoff-disabled'`(仪式按设计被跳过),**且**新会话交接材料里出现「未启用白板/账本」自述、**不含**磁盘上真实存在的 PLAN/账本正文 | 解耦生效:白板关 ≠ 不能接续;且降级是**显式**的,不是静默丢弃 |
73
- | 4 | `prev-session pack built` → `auto-continue host-executed: … stopped=ok` | ④ 新会话建立;`stopped=ok` 与判据 2 互相印证 |
74
-
75
- **反向断言(同样重要)**:
76
- - 若 ④ 出现但**没有** 2 → 停旧回合没生效(或旧会话无 id,`stopped=no-old-session`),必须记下来;
77
- - 若旧回合被打断后**新会话没建起来** → 交接失败,比"并行"更糟,立即回滚并报告。
78
-
79
- ## 4. 排查(卡片不弹时按序查)
80
-
81
- 1. 开关是否真的生效:`GET /api/dsh-auto-memory-pre/auto-continue-state`(或设置页)看 `enabled`;
82
- 2. 日志有没有 `consolidate skip:` / `auto-continue` 任何一行;
83
- 3. `modelKnown` 是否为真:首轮必为假(设计如此),只在**轮末**才可能 arm;
84
- 4. 冷却:`autoContinueCooldownMinutes=30`,触发过一次后 30 分钟内不再弹——**验收期间只点一次**,要点第二次就先把它调小。
85
-
86
- ## 5. 回滚(验完必做)
87
-
88
- ```pwsh
89
- Copy-Item "$env:USERPROFILE\.dsh\dsh-auto-memory-pre.json.bak-accept35" "$env:USERPROFILE\.dsh\dsh-auto-memory-pre.json" -Force
90
- ```
91
- 然后重启 dsh web。**除非用户明确要求长期开启**,否则恢复成 `handoffEnabled=false` / `autoContinueEnabled=false` / `autoContinueThreshold=0.75`。
92
-
93
- > 待用户裁定(会写进交接账本):这两个开关**日常要不要开**。若长期关着,则 #35 与 #31 第二半这两笔修复在水位/接续/白板面上**处于不生效状态**——修了但不在线。
94
-
95
- ## 6. 纪律
96
-
97
- - 改配置**必须先备份**(用户级硬规则);只改目标键,别整篇重写。
98
- - **不要**按进程名杀进程(曾误杀 harness);要停就用精确 PID + `taskkill /PID <pid> /T /F`。
99
- - 不要拿主对话当接续靶子。
100
- - 验收结论无论通过与否都要落一笔到当日日志;失败要附原始日志片段,不要只写"没通过"。
101
-
102
- ## 7. 真机验收结果(2026-09-14 17:37–17:41,Run 1)
103
-
104
- > 现场:旧会话 `session-d2c13583`(128 msgs)→ 新会话 `session-b8f093e4`。**在飞配置为 `handoffEnabled=true`**(非本文档 §2 设想的 A 轮),故本次实为 **B 轮**(完整两层载体);**A 轮(白板关)仍未验**。
105
- > 验收人操作:用户在新窗口点「同意接续」(`decideAutoContinue('agree')`)。
106
-
107
- ### 判据结果
108
-
109
- | # | 结果 | 证据 |
110
- |---|---|---|
111
- | 1 | **PASS** | `09:36:52.739Z auto-continue armed: sid=session-d2c13583… ratio=0.07 awaitIdle=true` |
112
- | 2 | **PASS** | `09:37:37.776Z auto-continue: stopped old turn sid=session-d2c13583…`;旧会话 `turn/end reason.kind=aborted/user` |
113
- | 3 | **FAIL(原因已查清,非逻辑缺陷)** | `09:39:08.082Z host refresh ritual: timeout, continuing with current material` → `lastOk.refreshRitual='timeout'` |
114
- | 4 | **PASS** | `09:39:08.106Z prev-session pack built … contSeq=22` → `09:39:08.247Z auto-continue host-executed: new session session-b8f093e4… ritual=timeout stopped=ok` |
115
-
116
- **反向断言**:④ 出现且 ② 同时出现 → 停旧回合生效;新会话成功建立(侧栏标题 `接续 #22 · dsh-auto-memory`,工作区/模型/权限 `deepseek-v4.1-flash` + `danger-full-access` 均继承)→ 无"比并行更糟"的交接失败。
117
-
118
- ### 判据 3 超时的根因(已用旧会话原始事件流取证)
119
-
120
- 不是判据写错,是**上游 API 故障把仪式推后到轮询窗之外**:
121
-
122
- 1. `09:37:37.778Z` 仪式以 `mode:'queue'` 投给旧会话,**立刻**落为 `seq=244 user/message`,`turn=3` 同步启动 → 投递链路本身正常。
123
- 2. `turn=3` 全窗没有一次成功的 assistant 产出:`llm/retry` 连续 5 次(`09:37:45`→`09:38:38`,61s),
124
- `09:38:38.450Z turn/end reason.kind=error`,错误为 **`502: {"message":"上游拒绝请求","type":"upstream_bad_request"}`**。
125
- 3. 90s 轮询窗(`autoContinueRefreshTimeoutSeconds`)到点 → 报 `timeout`,宿主按 fail-soft 继续接续。
126
- 4. **仪式随后真的执行了**:用户后续「继续」推动 `turn=6`,`09:41:01.529Z`(seq 291–295)模型连调两次
127
- `memory_note_pre`(`kind=plan` + `kind=handoff`)→ `PLAN.md` 与 `handoff-20260914-174101.md` 的 mtime 正是 `17:41:01`。
128
-
129
- 结论:**因果闭合判据的实质成立**(事件数增长且出现 seq>239 的 `assistant/message` + `tool/call`),只是发生在 90s 窗之后(+114s)。**记为半通过**,按本文档 §3 的口径单独记录;若要让它真通过,应提高 `autoContinueRefreshTimeoutSeconds`(当前上限 600)。
130
-
131
- ### 本轮新发现的真实风险:新会话会被立刻再次 arm(链式接续)
132
-
133
- - `09:42:49.340Z auto-continue armed: sid=session-b8f093e4…`(**即刚建出来的新会话**)→ 若无人干预,约 35s 后会再次掐掉新会话并再建一个。
134
- - 机制:`markContinuedSession(oldSid, newId)` 只给**旧**会话上闩(`isContinuedSession` 检查的是旧 sid);新会话是全新身份,不受闩保护。而新会话首题就是超大交接包,`ratio` 开局即 ≈0.053。
135
- - 本次是阈值降到 `0.05` 的**验收态放大**了它;生产阈值 `0.75` 下新会话开局不可能越线,故**当前不判定为缺陷**,但值得在 3.1 决策:新会话是否也应有一段"免接续蜜月期"。
136
- - **已处置**:`POST /auto-continue-decide {action:'reject'}` 已拆引信(`rejectedEdgeAt=1789378969340`),armed 清空。
137
-
138
- ### 收尾状态
139
-
140
- - 配置已按 §5 回滚:`autoContinueEnabled=false` / `autoContinueThreshold=0.75` / `autoContinueCooldownMinutes=30`(`handoffEnabled` 保持 `true`,与备份一致)。sha `3DEE4041F3E9`。
141
- - 回滚已**热生效**(`GET /api/dsh-auto-memory-pre/config` 内部会 `loadConfig()` 覆盖内存态;无需重启即生效,实测 `enabled:false`)——这与本文档 §2"每轮改完重启 dsh web"的旧说法不同,**热重载可用**。
142
- - 验收态快照留存:`~/.dsh/dsh-auto-memory-pre.json.bak-accept35-run1-evidence`。
143
- - **未验**:A 轮(`handoffEnabled=false`,判据 3′)、B 档"回合运行中点同意"(本次点同意时旧回合已自行 abort,`cancel` 对空转回合是空操作)。