@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.29

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 (79) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +56 -12
  4. package/data/yunnan-pack.json +30 -27
  5. package/dist/capabilities/anything.js +26 -0
  6. package/dist/capabilities/channel-registry.js +4 -4
  7. package/dist/capabilities/doctor.js +14 -7
  8. package/dist/capabilities/effect.js +1 -1
  9. package/dist/capabilities/ground-transfer.js +168 -1
  10. package/dist/capabilities/provider-detail-sanitize.js +64 -0
  11. package/dist/capabilities/route-provider-conformance.js +341 -0
  12. package/dist/capabilities/session/extension-bridge.js +3 -3
  13. package/dist/capabilities/session-search.js +1 -1
  14. package/dist/scripts/anything-path-measure-tests.js +133 -0
  15. package/dist/scripts/anything-path-measure.js +150 -0
  16. package/dist/scripts/anything-tests.js +136 -2
  17. package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
  18. package/dist/scripts/bootstrap-tests.js +77 -7
  19. package/dist/scripts/city-scenario-tier-tests.js +234 -0
  20. package/dist/scripts/doctor-tests.js +39 -3
  21. package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
  22. package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
  23. package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
  24. package/dist/scripts/dsh-llm-tests.js +437 -0
  25. package/dist/scripts/g5-guard-tests.js +20 -1
  26. package/dist/scripts/g5-guard.js +5 -1
  27. package/dist/scripts/gotry-backend-kernel-tests.js +1 -1
  28. package/dist/scripts/m3-cohort-tests.js +714 -0
  29. package/dist/scripts/m3-cohort.js +310 -0
  30. package/dist/scripts/nightly-evidence-tests.js +44 -2
  31. package/dist/scripts/nightly-evidence.js +21 -4
  32. package/dist/scripts/outcome-projection-tests.js +427 -0
  33. package/dist/scripts/persona-sim-tests.js +621 -0
  34. package/dist/scripts/persona-sim.js +1081 -0
  35. package/dist/scripts/replay-async.js +2 -0
  36. package/dist/scripts/replay.js +2 -0
  37. package/dist/scripts/route-provider-conformance-tests.js +391 -0
  38. package/dist/scripts/session-dida-live-e2e.js +1 -1
  39. package/dist/scripts/session-zone-crash.js +49 -0
  40. package/dist/scripts/session-zone-ledger-tests.js +432 -0
  41. package/dist/scripts/session-zone-observation-tests.js +492 -0
  42. package/dist/scripts/session-zone-observe.js +135 -0
  43. package/dist/scripts/session-zone-wiring-tests.js +566 -0
  44. package/dist/scripts/session-zones-tests.js +17 -1
  45. package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
  46. package/dist/scripts/state-cli.js +16 -0
  47. package/dist/scripts/time-eval-tests.js +2 -0
  48. package/dist/scripts/unified-tests.js +79 -2
  49. package/dist/scripts/z3-race-tests.js +6 -1
  50. package/dist/src/backend/modules/session-search.js +1 -1
  51. package/dist/src/city-scenario-tier.js +378 -0
  52. package/dist/src/dsh-llm.js +84 -14
  53. package/dist/src/index.js +166 -7
  54. package/dist/src/loop.js +16 -5
  55. package/dist/src/m3-cohort.js +1577 -0
  56. package/dist/src/outcome-projection.js +746 -0
  57. package/dist/src/session-zone-ledger.js +312 -0
  58. package/dist/src/session-zone-observation.js +354 -0
  59. package/dist/src/session-zone-wiring.js +658 -0
  60. package/dist/src/session-zones.js +7 -4
  61. package/dist/src/state-ledger.js +293 -40
  62. package/dist/src/unified.js +69 -5
  63. package/extension/README.md +1 -1
  64. package/extension/README.zh-CN.md +1 -1
  65. package/extension/background.js +1 -1
  66. package/extension/content-bridge.js +1 -1
  67. package/extension/content-main.js +1 -1
  68. package/extension/manifest.json +2 -2
  69. package/package.json +1 -1
  70. package/ts/capabilities/anything.ts +27 -0
  71. package/ts/capabilities/session/extension-bridge.ts +3 -3
  72. package/ts/capabilities/session-search.ts +1 -1
  73. package/ts/scripts/state-cli.ts +18 -0
  74. package/ts/src/contracts.ts +4 -0
  75. package/ts/src/dsh-llm.ts +111 -9
  76. package/ts/src/index.ts +181 -8
  77. package/ts/src/loop.ts +15 -5
  78. package/ts/src/state-ledger.ts +294 -40
  79. package/ts/src/unified.ts +91 -18
@@ -17,6 +17,16 @@
17
17
  * 引擎:better-sqlite3(复用矩阵 open-source import 通道),WAL + NORMAL,
18
18
  * 同步 API 与仓内 readFileSync 风格一致;busy_timeout 容忍跨进程
19
19
  * (dsh 运行时 / async-collect / state-cli tick 并存)。
20
+ *
21
+ * 跨进程争用纪律(D-15 演练 D15-1/D15-2 的修复,issue #275 已于 2026-10-05
22
+ * 作为 deferred 关闭——触发出现时另开新 issue):
23
+ * - busy_timeout 必须在任何可争用语句之前装好(含 journal_mode = WAL);
24
+ * - 建表/迁移整段在 BEGIN IMMEDIATE 里做,版本探测与建表同处一个写锁;
25
+ * - 读改写事务一律 BEGIN IMMEDIATE(先读后写的 DEFERRED 事务升级锁时,
26
+ * SQLite 直接返回 SQLITE_BUSY_SNAPSHOT 且不调用 busy handler);
27
+ * - 争用重试有界,耗尽后抛 LedgerBusyError(典型化),不再漏裸 SQLITE_BUSY*。
28
+ * 单写者产品形态(ADR-16 一个账本一个 owner)本来就不会走到这些竞态,
29
+ * 以上只改争用路径的行为,单进程行为逐字节不变。
20
30
  */
21
31
 
22
32
  import Database from 'better-sqlite3'
@@ -77,6 +87,108 @@ function isUniqueViolation(e: unknown): boolean {
77
87
  return e instanceof Error && (e as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE'
78
88
  }
79
89
 
90
+ // ---- 跨进程争用:有界重试 + 典型化出口(D15-1/D15-2) --------------------------
91
+
92
+ /** 写锁等待上限;跨进程(dsh 运行时 / async-collect / state-cli tick)共存的容忍面。 */
93
+ const BUSY_TIMEOUT_MS = 5_000
94
+ /** 争用重试次数上限(首次尝试之外);退避总额 < 400ms,宁可早报错也不无限等。 */
95
+ const CONTENTION_RETRIES = 5
96
+ /**
97
+ * 打开路径(WAL 切换 / 建表迁移)另按**墙钟**封顶:冷启动竞赛里失败往往是立即
98
+ * 抛出(busy handler 根本没被调用),次数上限会退化成「次数 × busy_timeout」那么长。
99
+ * 一次 open 最多争用 5s —— 正好等于 busy_timeout 的语义,然后给典型化错误。
100
+ */
101
+ const OPEN_CONTENTION_BUDGET_MS = 5_000
102
+ const OPEN_CONTENTION_RETRIES = 20
103
+
104
+ /**
105
+ * SQLite 把「此刻拿不到锁」编码成这几个码:
106
+ * SQLITE_BUSY / _TIMEOUT 写锁被别的连接占着(busy handler 已耗尽)
107
+ * SQLITE_BUSY_SNAPSHOT 读快照过期、无法升级为写(DEFERRED 读改写的死法)
108
+ * SQLITE_BUSY_RECOVERY WAL 正在被别的连接恢复
109
+ * SQLITE_PROTOCOL WAL index(-shm)争用导致的锁协议重试
110
+ * 只有这几个码允许重试;其余码(约束冲突/IO 错误/逻辑错)一律原样抛出——
111
+ * 真错误不得被重试掩盖成「忙」。
112
+ */
113
+ const CONTENTION_CODES: ReadonlySet<string> = new Set([
114
+ 'SQLITE_BUSY',
115
+ 'SQLITE_BUSY_TIMEOUT',
116
+ 'SQLITE_BUSY_SNAPSHOT',
117
+ 'SQLITE_BUSY_RECOVERY',
118
+ 'SQLITE_PROTOCOL',
119
+ ])
120
+
121
+ function sqliteCodeOf(e: unknown): string | undefined {
122
+ const code = (e as { code?: unknown } | null | undefined)?.code
123
+ return typeof code === 'string' ? code : undefined
124
+ }
125
+
126
+ function isContention(e: unknown): boolean {
127
+ const code = sqliteCodeOf(e)
128
+ return code !== undefined && CONTENTION_CODES.has(code)
129
+ }
130
+
131
+ /**
132
+ * 典型化的账本争用错误:有界重试耗尽后的唯一出口。
133
+ *
134
+ * 修复前跨进程争用会把 better-sqlite3 的裸 SQLITE_BUSY / SQLITE_BUSY_SNAPSHOT
135
+ * 直接扔给调用方(D15-1/D15-2),调用方只能嗅探字符串码。现在调用方可以
136
+ * `instanceof LedgerBusyError` 判定「只是忙、可稍后再来」,底层码仍保留在
137
+ * `sqliteCode`/`cause` 里供诊断。
138
+ */
139
+ export class LedgerBusyError extends Error {
140
+ /** 与 better-sqlite3 的 `code` 面同形,便于统一嗅探;永不与 SQLITE_* 撞名。 */
141
+ readonly code = 'GOTRY_LEDGER_BUSY'
142
+ readonly op: string
143
+ readonly attempts: number
144
+ readonly sqliteCode: string | undefined
145
+
146
+ constructor(op: string, attempts: number, cause: unknown) {
147
+ super(`ledger busy: ${op} 在 ${attempts} 次尝试后仍未取得写锁(末次 SQLite 码 ${sqliteCodeOf(cause) ?? 'unknown'})`, { cause })
148
+ this.name = 'LedgerBusyError'
149
+ this.op = op
150
+ this.attempts = attempts
151
+ this.sqliteCode = sqliteCodeOf(cause)
152
+ }
153
+ }
154
+
155
+ /** 同步退避:better-sqlite3 全同步,不能 await;Atomics.wait 是仓内既有惯用法。 */
156
+ function sleepSync(ms: number): void {
157
+ if (ms <= 0) return
158
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
159
+ }
160
+
161
+ /** 指数退避 + pid 抖动:N 个进程同时冷启动时不会同节奏再撞第二次。 */
162
+ function backoffMs(attempt: number): number {
163
+ return Math.min(5 * 2 ** attempt, 160) + (process.pid % 11)
164
+ }
165
+
166
+ /**
167
+ * 有界重试争用码,耗尽即抛 LedgerBusyError。
168
+ *
169
+ * 只对「整体提交或整体回滚」的单元使用:事务函数回滚后守门纯函数会重新读当前
170
+ * 状态再算一遍,所以重跑是幂等的(事件另有 idem_key UNIQUE 兜底)。
171
+ */
172
+ function withContentionRetry<T>(op: string, fn: () => T, opts: { retries?: number; budgetMs?: number } = {}): T {
173
+ const retries = opts.retries ?? CONTENTION_RETRIES
174
+ const deadline = opts.budgetMs === undefined ? undefined : Date.now() + opts.budgetMs
175
+ let lastError: unknown
176
+ let attempts = 0
177
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
178
+ attempts += 1
179
+ try {
180
+ return fn()
181
+ } catch (e) {
182
+ if (!isContention(e)) throw e
183
+ lastError = e
184
+ if (attempt >= retries) break
185
+ if (deadline !== undefined && Date.now() >= deadline) break
186
+ sleepSync(backoffMs(attempt))
187
+ }
188
+ }
189
+ throw new LedgerBusyError(op, attempts, lastError)
190
+ }
191
+
80
192
  const SCHEMA = `
81
193
  CREATE TABLE IF NOT EXISTS events (
82
194
  seq INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -262,6 +374,50 @@ export class StateLedger {
262
374
  // ---- 写:单事务{INSERT event; UPDATE 投影},守门纯函数原样复用 -----------------
263
375
  // 红线进事务:evidence 缺失/conditions 空 → mergeProfile/校验拒绝 → 事务回滚,账本无痕。
264
376
 
377
+ /**
378
+ * 读改写事务的统一出口(D15-2 修复)。
379
+ *
380
+ * 为什么必须 BEGIN IMMEDIATE:DEFERRED 事务的第一条语句是 SELECT 时,SQLite 只
381
+ * 取读快照;等后面要写时若已有别的写者提交过,快照无法升级,SQLite **立即**返回
382
+ * SQLITE_BUSY_SNAPSHOT 并且**不调用 busy handler**(等待毫无意义,快照已过期)。
383
+ * 所以 busy_timeout 对这条路径完全无效——演练里 appendWish 需要调用方自己重试
384
+ * 6~9 次才做完 12 次尝试,就是这个原因。IMMEDIATE 开头就拿写锁,后来者改为在
385
+ * busy handler 里排队等待,SQLITE_BUSY_SNAPSHOT 在物理上不再可能出现。
386
+ *
387
+ * 重试是 IMMEDIATE 之外的第二道兜底(busy_timeout 真被耗尽时),整体回滚后重算,
388
+ * 幂等;耗尽 → LedgerBusyError。已在外层事务里(嵌套 = SAVEPOINT,模式被
389
+ * better-sqlite3 忽略)时不自行重试:重试归最外层事务所有,否则会在一个已经
390
+ * 注定回滚的事务里空转。
391
+ */
392
+ private runWrite<T>(op: string, fn: () => T): T {
393
+ const run = this.db.transaction(fn)
394
+ if (this.db.inTransaction) return run.immediate()
395
+ return withContentionRetry(`ledger.${op}`, () => run.immediate())
396
+ }
397
+
398
+ /**
399
+ * 全量事务审计(D15-2):判据只有一条——**事务的第一条语句是不是 SELECT**。
400
+ * 是 → 先读后写,会撞 SQLITE_BUSY_SNAPSHOT,必须走 runWrite(IMMEDIATE+有界重试);
401
+ * 不是(INSERT/UPDATE/DELETE 开头)→ 写锁在第一条语句就拿到,快照升级在物理上
402
+ * 不会发生,维持 DEFERRED 逐字节同义,不扩大改动面。
403
+ *
404
+ * runWrite(IMMEDIATE) appendMotivationPatch readMotivation 开头
405
+ * appendWish readWishPool 开头(演练实测失败点)
406
+ * appendTripEvent readTrips 开头
407
+ * appendCompanion readCompanions 开头
408
+ * confirmOutcome full 为空时以 readTrips 开头
409
+ * importLegacyInto SELECT kv.legacy_imported_v1 开头
410
+ * 维持 DEFERRED appendUtilityEvent 单条 INSERT
411
+ * rebuildProjections DELETE 投影开头(读在写锁之内)
412
+ * createWorkflowRun INSERT workflow_runs 开头
413
+ * finishWorkflowRun UPDATE workflow_runs 开头
414
+ * requestPendingWrite INSERT pending_writes 开头
415
+ * confirmPendingWrite UPDATE pending_writes 开头
416
+ * compensatePendingWrite UPDATE pending_writes 开头
417
+ * forgetSubject DELETE events 开头
418
+ * openDb 的建表/迁移事务另见 ensureSchema(D15-1)。
419
+ */
420
+
265
421
  /** 动机画像增量补丁(守门 = mergeProfile:追加不删史/幂等/权重变更须伴新证据) */
266
422
  appendMotivationPatch(patch: ProfilePatch, actor = 'tool:gotry_motivation_save'): {
267
423
  saved: boolean
@@ -270,7 +426,8 @@ export class StateLedger {
270
426
  // 事件边界 ISO:事件行的 ts 就是 homeCityPreference.updated_at 的源头,
271
427
  // fold 路径会读同一字段回放——rebuildProjections 必须能复现该值。
272
428
  const ts = new Date().toISOString()
273
- const run = this.db.transaction((): { saved: boolean; profile: MergedProfile & { updated_at?: string } } => {
429
+ // 读改写(readMotivation → mergeProfile → 写投影):第一条语句是 SELECT → IMMEDIATE。
430
+ return this.runWrite('appendMotivationPatch', (): { saved: boolean; profile: MergedProfile & { updated_at?: string } } => {
274
431
  const current = this.readMotivation()
275
432
  const merged = mergeProfile(current, patch, ts)
276
433
  if (!merged) {
@@ -285,7 +442,6 @@ export class StateLedger {
285
442
  ).run(this.tenant, JSON.stringify(doc))
286
443
  return { saved: true, profile: doc }
287
444
  })
288
- return run()
289
445
  }
290
446
 
291
447
  /** 愿望入池/同名幂等更新(红线:conditions 必填非空) */
@@ -301,7 +457,9 @@ export class StateLedger {
301
457
  throw new Error('wish pool entry requires name and conditions (fulfilment conditions are the whole point)')
302
458
  }
303
459
  const ts = new Date().toISOString()
304
- const run = this.db.transaction((): { added: boolean; wish_id: string; total: number } => {
460
+ // 读改写(readWishPool → 同名判定 → 写事件+投影):第一条语句是 SELECT → IMMEDIATE。
461
+ // 这是 D15-2 演练里实际失败的那条路径。
462
+ return this.runWrite('appendWish', (): { added: boolean; wish_id: string; total: number } => {
305
463
  const pool = this.readWishPool()
306
464
  const existing = pool.find(w => String(w.name ?? '') === name)
307
465
  if (existing) {
@@ -330,13 +488,14 @@ export class StateLedger {
330
488
  this.insertItem('wish_pool', id, doc, seq ?? 0)
331
489
  return { added: true, wish_id: id, total: pool.length + 1 }
332
490
  })
333
- return run()
334
491
  }
335
492
 
336
493
  /** 效用事件(幂等键 = 语义 event_id;verified 无归因自动降级 applied——纯函数同款纪律) */
337
494
  appendUtilityEvent(ev: Omit<MemoryUtilityEvent, 'schema' | 'event_id'>, actor = 'tool:gotry_wish_pool_list'): { appended: boolean; events: MemoryUtilityEvent[] } {
338
495
  const full = gateUtilityEvent([], ev).events[0]
339
496
  if (!full) return { appended: false, events: this.readUtilityEvents() }
497
+ // 非读改写:事务内只有一条 INSERT,写锁在第一条语句就拿到,不存在快照升级。
498
+ // 维持 DEFERRED(与修复前逐字节同义),争用只会落到 busy_timeout 等待上。
340
499
  const run = this.db.transaction((): boolean => {
341
500
  return this.insertEvent({ actor, kind: 'memory_utility.event', subjectId: full.wish_id, payload: { event: full }, idemKey: `mu:${full.event_id}` }) !== null
342
501
  })
@@ -345,7 +504,9 @@ export class StateLedger {
345
504
 
346
505
  /** 时间线行程(守门 = appendTrip:必填/绝对日期/重叠冲突即停;幂等键 = trip_id) */
347
506
  appendTripEvent(ev: Omit<TimelineEvent, 'schema' | 'trip_id' | 'ts'>, actor = 'tool:gotry_trip_log'): { appended: boolean; tripId?: string; reason?: string; total: number } {
348
- const run = this.db.transaction((): { appended: boolean; tripId?: string; reason?: string; total: number } => {
507
+ // 读改写(readTrips → 重叠冲突判定 → 写事件):第一条语句是 SELECT → IMMEDIATE。
508
+ // 重叠判定必须看见提交后的全量行程,否则两个进程可以各写一条互相重叠的行程。
509
+ return this.runWrite('appendTripEvent', (): { appended: boolean; tripId?: string; reason?: string; total: number } => {
349
510
  const existing = this.readTrips()
350
511
  const gate = gateTrip(existing, ev)
351
512
  if (!gate.appended) return { appended: false, tripId: gate.tripId, reason: gate.reason, total: existing.length }
@@ -353,12 +514,13 @@ export class StateLedger {
353
514
  this.insertEvent({ actor, kind: 'trip.logged', subjectId: full.trip_id, payload: { trip: full }, idemKey: `trip:${full.trip_id}` })
354
515
  return { appended: true, tripId: full.trip_id, total: gate.events.length }
355
516
  })
356
- return run()
357
517
  }
358
518
 
359
519
  /** 同行人档案(守门 = upsertCompanion:负面清单拒收/追加不删史/幂等) */
360
520
  appendCompanion(patch: { label: string; constraints: CompanionConstraints; evidence: string }, actor = 'tool:gotry_companion_save'): { appended: boolean; companionId: string; reason?: string; total: number } {
361
- const run = this.db.transaction((): { appended: boolean; companionId: string; reason?: string; total: number } => {
521
+ // 读改写(readCompanions → upsertCompanion → DELETE+INSERT 整组投影):
522
+ // 第一条语句是 SELECT → IMMEDIATE。replaceItems 整组重写,丢更新的代价最大。
523
+ return this.runWrite('appendCompanion', (): { appended: boolean; companionId: string; reason?: string; total: number } => {
362
524
  const profiles = this.readCompanions()
363
525
  const res = upsertCompanion(profiles, patch)
364
526
  if (!res.appended) return { appended: false, companionId: res.companionId, reason: res.reason, total: profiles.length }
@@ -366,7 +528,6 @@ export class StateLedger {
366
528
  this.replaceItems('companions', res.profiles.map(p => [p.companion_id, p]))
367
529
  return { appended: true, companionId: res.companionId, total: res.profiles.length }
368
530
  })
369
- return run()
370
531
  }
371
532
 
372
533
  /**
@@ -382,7 +543,9 @@ export class StateLedger {
382
543
  recorded: boolean
383
544
  trip?: { appended: boolean; tripId?: string; reason?: string }
384
545
  } {
385
- const run = this.db.transaction((): { recorded: boolean; trip?: { appended: boolean; tripId?: string; reason?: string } } => {
546
+ // 读改写:行程分支里 readTrips → gateTrip → 写事件;且 full 为空时整个事务
547
+ // 以 SELECT 开头。两个写同一事务提交的原子性正是本方法的存在理由 → IMMEDIATE。
548
+ return this.runWrite('confirmOutcome', (): { recorded: boolean; trip?: { appended: boolean; tripId?: string; reason?: string } } => {
386
549
  const now = new Date().toISOString()
387
550
  const full = gateUtilityEvent([], {
388
551
  wish_id: input.wishId, kind: 'verified_outcome', ts: now,
@@ -403,7 +566,6 @@ export class StateLedger {
403
566
  }
404
567
  return { recorded, trip }
405
568
  })
406
- return run()
407
569
  }
408
570
 
409
571
  // ---- 投影维护 ---------------------------------------------------------------
@@ -442,6 +604,8 @@ export class StateLedger {
442
604
  * 与写路径共用同一套纯函数 → 直读投影与 fold 结果一致(账本/投影永不分叉的可验证性)。
443
605
  */
444
606
  rebuildProjections(toSeq?: number): { events: number; wishes: number; companions: number } {
607
+ // DELETE 开头 → 写锁即刻到手,维持 DEFERRED(审计表见 runWrite 上方);
608
+ // 且本方法常被 forgetSubject/importLegacyInto 嵌套调用,嵌套层不该自己重试。
445
609
  const run = this.db.transaction((): { events: number; wishes: number; companions: number } => {
446
610
  this.db.prepare('DELETE FROM projection_docs WHERE tenant_id = ?').run(this.tenant)
447
611
  this.db.prepare('DELETE FROM projection_items WHERE tenant_id = ?').run(this.tenant)
@@ -517,6 +681,7 @@ export class StateLedger {
517
681
 
518
682
  createWorkflowRun(input: { id: string; goal: string; ticket: unknown; state: unknown }, actor = 'system:async-request'): void {
519
683
  const ts = new Date().toISOString()
684
+ // INSERT 开头 → 维持 DEFERRED(审计表见 runWrite 上方)。
520
685
  const run = this.db.transaction(() => {
521
686
  this.db.prepare(
522
687
  `INSERT INTO workflow_runs (tenant_id, id, goal, status, ticket_json, state_json, deliverable, created, updated)
@@ -561,6 +726,7 @@ export class StateLedger {
561
726
  terminalOutcome?: unknown,
562
727
  actor = 'system:async-collect',
563
728
  ): void {
729
+ // UPDATE ... WHERE status = 'pending' 开头(CAS 式状态推进)→ 维持 DEFERRED。
564
730
  const run = this.db.transaction(() => {
565
731
  const update = this.db.prepare(
566
732
  `UPDATE workflow_runs SET status = ?, deliverable = ?, updated = ?
@@ -611,6 +777,7 @@ export class StateLedger {
611
777
  /** 登记一笔待确认外部写(幂等键去重:同一确认不可能登记两次)。L2 = 只登记不执行。 */
612
778
  requestPendingWrite(input: { idemKey: string; seam: string; payload: unknown }, actor = 'system:writegate'): { created: boolean; status: string } {
613
779
  const ts = new Date().toISOString()
780
+ // INSERT ... ON CONFLICT DO NOTHING 开头 → 维持 DEFERRED。
614
781
  const run = this.db.transaction((): { created: boolean; status: string } => {
615
782
  const info = this.db.prepare(
616
783
  `INSERT INTO pending_writes (tenant_id, idem_key, seam, payload, status, created, updated)
@@ -628,6 +795,7 @@ export class StateLedger {
628
795
 
629
796
  /** L3:具名 seam 确认——pending → confirmed,携 receipt(外部世界回执) */
630
797
  confirmPendingWrite(idemKey: string, receipt: string, actor = 'system:writegate'): { ok: boolean; status: string } {
798
+ // UPDATE ... WHERE status = 'pending' 开头(CAS)→ 维持 DEFERRED。
631
799
  const run = this.db.transaction((): { ok: boolean; status: string } => {
632
800
  const info = this.db.prepare(
633
801
  `UPDATE pending_writes SET status = 'confirmed', receipt = ?, updated = ? WHERE idem_key = ? AND status = 'pending' AND tenant_id = ?`,
@@ -644,6 +812,7 @@ export class StateLedger {
644
812
 
645
813
  /** saga 补偿:pending/confirmed → compensated(SagaLLM 式补偿语义) */
646
814
  compensatePendingWrite(idemKey: string, note: string, actor = 'system:writegate'): { ok: boolean; status: string } {
815
+ // UPDATE ... WHERE status != 'compensated' 开头(CAS)→ 维持 DEFERRED。
647
816
  const run = this.db.transaction((): { ok: boolean; status: string } => {
648
817
  const info = this.db.prepare(
649
818
  `UPDATE pending_writes SET status = 'compensated', receipt = COALESCE(receipt, ?), updated = ? WHERE idem_key = ? AND status != 'compensated' AND tenant_id = ?`,
@@ -672,6 +841,7 @@ export class StateLedger {
672
841
 
673
842
  /** 物理删除某主体的全部事件并重建投影;删除本身留一行审计事件(红线 6「可删除」)。 */
674
843
  forgetSubject(subjects: Array<{ kinds: string[]; subjectId: string }>, actor = 'system:state-cli'): { deleted: number } {
844
+ // DELETE events 开头 → 维持 DEFERRED(审计表见 runWrite 上方)。
675
845
  const run = this.db.transaction((): { deleted: number } => {
676
846
  let deleted = 0
677
847
  for (const s of subjects) {
@@ -698,25 +868,51 @@ export class StateLedger {
698
868
 
699
869
  const openLedgers = new Map<string, StateLedger>()
700
870
 
701
- export function openDb(stateRoot: string, tenant = 'local'): StateLedger {
702
- const path = ledgerDbPath(stateRoot)
703
- const cacheKey = `${path}::${tenant}`
704
- const cached = openLedgers.get(cacheKey)
705
- if (cached) return cached
706
- mkdirSync(dirname(path), { recursive: true })
707
- const db = new Database(path)
708
- db.pragma('journal_mode = WAL')
709
- db.pragma('synchronous = NORMAL')
710
- db.pragma('busy_timeout = 5000')
711
- // schema v1→v2 迁移:v1 无 tenant_id 列(user_version 未设置过,实际为 0)
712
- const version = db.pragma('user_version', { simple: true }) as number
713
- if (version < 2) {
714
- // 检测是否真有 v1 数据(有 events 表但无 tenant_id 列)
715
- const hasEventsTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='events'").get() !== undefined
716
- const eventsHasTenant = hasEventsTable && (db.prepare("PRAGMA table_info(events)").all() as Array<{ name: string }>).some(c => c.name === 'tenant_id')
717
- const isV1 = hasEventsTable && !eventsHasTenant
718
-
719
- db.transaction(() => {
871
+ /**
872
+ * WAL 切换(D15-1 的第一个抛点)。
873
+ *
874
+ * 切成 WAL 要拿排他锁,而 `PRAGMA journal_mode` 并非在所有路径上都会走 busy
875
+ * handler,所以除了「busy_timeout 先装好」之外还要有界重试。每次失败先回看当前
876
+ * 模式:别的进程已经切成 wal 就直接收工——冷启动竞赛里这是最常见的结局。
877
+ *
878
+ * 不抛而仍未切成 wal(例如不支持 WAL 的文件系统)时保持修复前行为:照旧继续,
879
+ * 不编造「忙」。只有真的抛争用码且重试耗尽才给 LedgerBusyError。
880
+ */
881
+ function ensureWalMode(db: Database.Database): void {
882
+ const currentMode = (): string => String(db.pragma('journal_mode', { simple: true }) ?? '').toLowerCase()
883
+ if (currentMode() === 'wal') return
884
+ withContentionRetry('openDb journal_mode = WAL', () => {
885
+ if (currentMode() === 'wal') return // 竞赛里最常见的结局:别的进程已经切好了
886
+ db.pragma('journal_mode = WAL')
887
+ }, { retries: OPEN_CONTENTION_RETRIES, budgetMs: OPEN_CONTENTION_BUDGET_MS })
888
+ }
889
+
890
+ /**
891
+ * 建表/迁移(D15-1 的第二个抛点):整段进 BEGIN IMMEDIATE + 有界重试。
892
+ *
893
+ * 修复前版本探测在事务**外**,两个冷启动进程会各自读到 user_version = 0,再同时
894
+ * 进 DEFERRED 事务执行 `db.exec(SCHEMA)` —— 第一条 DDL 要升级成写,输的那个直接
895
+ * 抛裸 SQLITE_BUSY。现在版本探测与建表同处一个写锁下,后来者在 busy handler 里
896
+ * 排队,进去后重新读到 user_version = 2,于是只跑一遍幂等建表。
897
+ *
898
+ * 返回 isV1:v1→v2 升级后要重建投影,而那需要 StateLedger 实例,只能在事务外做。
899
+ */
900
+ function ensureSchema(db: Database.Database): boolean {
901
+ // 快路径:schema 已是 v2、kv 旗标在位、历史坏迁移已修 → 纯读探测,完全不取写锁。
902
+ // (修复前每次 open 都要写一次 kv schema_version,连只读调用方也在抢写锁,
903
+ // 冷启动竞赛因此更密;值本来就幂等,不写与写同义。)
904
+ if (schemaAlreadyCurrent(db)) return false
905
+ const kvSchemaVersion = 'INSERT INTO kv (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v'
906
+ const migrate = db.transaction((): boolean => {
907
+ // 版本探测搬进事务:与建表同处一个写锁下,不再有两个进程各读到 0 的 TOCTOU。
908
+ const version = db.pragma('user_version', { simple: true }) as number
909
+ let isV1 = false
910
+ if (version < 2) {
911
+ // schema v1→v2 迁移:v1 无 tenant_id 列(user_version 未设置过,实际为 0)
912
+ // 检测是否真有 v1 数据(有 events 表但无 tenant_id 列)
913
+ const hasEventsTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='events'").get() !== undefined
914
+ const eventsHasTenant = hasEventsTable && (db.prepare('PRAGMA table_info(events)').all() as Array<{ name: string }>).some(c => c.name === 'tenant_id')
915
+ isV1 = hasEventsTable && !eventsHasTenant
720
916
  if (isV1) {
721
917
  // v1 → v2:events 加 tenant_id,回填 'local';投影/工单/pending_writes 强制重建(旧表无 tenant_id)
722
918
  db.exec(`
@@ -731,7 +927,7 @@ export function openDb(stateRoot: string, tenant = 'local'): StateLedger {
731
927
  `)
732
928
  } else {
733
929
  // v2 幂等:检查 workflow_runs 是否缺 tenant_id(修一次跑坏的迁移)
734
- const wrHasTenant = (db.prepare("PRAGMA table_info(workflow_runs)").all() as Array<{ name: string }>).some(c => c.name === 'tenant_id')
930
+ const wrHasTenant = (db.prepare('PRAGMA table_info(workflow_runs)').all() as Array<{ name: string }>).some(c => c.name === 'tenant_id')
735
931
  if (!wrHasTenant && db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='workflow_runs'").get()) {
736
932
  db.exec(`
737
933
  DROP TABLE IF EXISTS workflow_runs;
@@ -744,16 +940,68 @@ export function openDb(stateRoot: string, tenant = 'local'): StateLedger {
744
940
  }
745
941
  db.exec(SCHEMA)
746
942
  db.pragma('user_version = 2')
747
- db.prepare('INSERT INTO kv (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v').run('schema_version', SCHEMA_VERSION)
748
- })()
749
- // v1 升级后:旧单用户事件只可安全归入 local;即使调用者打开非 local 租户,也只重建 local 投影。
750
- if (isV1) {
751
- const ledger = new StateLedger(db, stateRoot, 'local')
752
- ledger.rebuildProjections()
943
+ db.prepare(kvSchemaVersion).run('schema_version', SCHEMA_VERSION)
944
+ } else {
945
+ // 已是 v2:只做幂等建表(IF NOT EXISTS)。坏迁移修补只属于 version < 2 分支,
946
+ // 与修复前一致——user_version 已经是 2 时绝不去 DROP 任何可能有数据的表。
947
+ db.exec(SCHEMA)
948
+ db.prepare(kvSchemaVersion).run('schema_version', SCHEMA_VERSION)
753
949
  }
754
- } else {
755
- db.exec(SCHEMA) // 幂等建表(IF NOT EXISTS)
756
- db.prepare('INSERT INTO kv (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v').run('schema_version', '2')
950
+ return isV1
951
+ })
952
+ return withContentionRetry('openDb schema ensure/migrate', () => migrate.immediate(), {
953
+ retries: OPEN_CONTENTION_RETRIES,
954
+ budgetMs: OPEN_CONTENTION_BUDGET_MS,
955
+ })
956
+ }
957
+
958
+ /** 当前 SCHEMA 应当存在的全部对象;快路径据此判定「慢路径必然是空操作」。 */
959
+ const SCHEMA_OBJECTS = ['events', 'events_idem', 'projection_docs', 'projection_items', 'workflow_runs', 'workflow_steps', 'pending_writes', 'kv'] as const
960
+
961
+ /**
962
+ * 纯读探测:user_version 已是 2、SCHEMA 全部对象在位、kv 旗标已等于目标值
963
+ * → 慢路径(exec SCHEMA + kv upsert)可证明是空操作,于是整段写锁都省掉。
964
+ * 任何一项不满足(新库,或别的进程正在建而本连接还看不到)→ false,走慢路径。
965
+ */
966
+ function schemaAlreadyCurrent(db: Database.Database): boolean {
967
+ try {
968
+ if ((db.pragma('user_version', { simple: true }) as number) < 2) return false
969
+ const present = new Set(
970
+ (db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table', 'index')").all() as Array<{ name: string }>).map(r => r.name),
971
+ )
972
+ if (!SCHEMA_OBJECTS.every(name => present.has(name))) return false
973
+ const row = db.prepare(`SELECT v FROM kv WHERE k = 'schema_version'`).get() as { v: string } | undefined
974
+ return row?.v === SCHEMA_VERSION
975
+ } catch {
976
+ return false
977
+ }
978
+ }
979
+
980
+ export function openDb(stateRoot: string, tenant = 'local'): StateLedger {
981
+ const path = ledgerDbPath(stateRoot)
982
+ const cacheKey = `${path}::${tenant}`
983
+ const cached = openLedgers.get(cacheKey)
984
+ if (cached) return cached
985
+ mkdirSync(dirname(path), { recursive: true })
986
+ const db = new Database(path)
987
+ let isV1 = false
988
+ try {
989
+ // D15-1 顺序红线:busy_timeout 必须在任何可争用语句之前装好。修复前它排在
990
+ // `journal_mode = WAL` 之后,于是 WAL 切换的排他锁被持有时,其余连接还没有
991
+ // busy handler —— 冷启动并发打开直接抛裸 SQLITE_BUSY。顺序不可再调换。
992
+ db.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`)
993
+ ensureWalMode(db)
994
+ db.pragma('synchronous = NORMAL')
995
+ isV1 = ensureSchema(db)
996
+ } catch (e) {
997
+ // 打开失败不留半开句柄(重试/上抛都不该泄漏 better-sqlite3 的文件描述符)。
998
+ try { db.close() } catch { /* 句柄已废,关不掉不影响裁决 */ }
999
+ throw e
1000
+ }
1001
+ // v1 升级后:旧单用户事件只可安全归入 local;即使调用者打开非 local 租户,也只重建 local 投影。
1002
+ if (isV1) {
1003
+ const ledger = new StateLedger(db, stateRoot, 'local')
1004
+ ledger.rebuildProjections()
757
1005
  }
758
1006
  const ledger = new StateLedger(db, stateRoot, tenant)
759
1007
  openLedgers.set(cacheKey, ledger)
@@ -802,7 +1050,13 @@ export function ensureLedger(stateRoot: string, tenant = 'local'): StateLedger {
802
1050
 
803
1051
  function importLegacyInto(ledger: StateLedger, dir: string): MigrationReport['imported'] {
804
1052
  const imported = { motivation: false, wishes: 0, utilityEvents: 0, trips: 0, companions: 0 }
1053
+ // 读改写:以 SELECT kv.legacy_imported_v1 开头(旗标判定)→ IMMEDIATE + 有界重试。
1054
+ // 「并发 ensure 不重复导入」靠的就是这个旗标,而 DEFERRED 事务下两个进程会同时
1055
+ // 读到无旗标、再在写时一个拿锁一个抛 SQLITE_BUSY_SNAPSHOT —— 正是 D15-2 的形状。
805
1056
  const run = ledger.db.transaction(() => {
1057
+ // 重试要幂等:计数器属于「本次事务的成果」,每次尝试重新归零,回滚过的那次
1058
+ // 不得残留在报告里。
1059
+ Object.assign(imported, { motivation: false, wishes: 0, utilityEvents: 0, trips: 0, companions: 0 })
806
1060
  const flag = ledger.db.prepare(`SELECT v FROM kv WHERE k = 'legacy_imported_v1'`).get() as { v: string } | undefined
807
1061
  if (flag) return
808
1062
  const readJsonSafe = (p: string): unknown | null => {
@@ -854,7 +1108,7 @@ function importLegacyInto(ledger: StateLedger, dir: string): MigrationReport['im
854
1108
  ledger.rebuildProjections()
855
1109
  ledger.db.prepare(`INSERT INTO kv (k, v) VALUES ('legacy_imported_v1', 'done') ON CONFLICT(k) DO UPDATE SET v = excluded.v`).run()
856
1110
  })
857
- run()
1111
+ withContentionRetry('ensureLedger legacy import', () => run.immediate())
858
1112
  return imported
859
1113
  }
860
1114
 
package/ts/src/unified.ts CHANGED
@@ -117,6 +117,13 @@ function routeHint(id: string): string | undefined {
117
117
  return SEGMENT_ROUTES[id]
118
118
  }
119
119
 
120
+ // Issue #620 注:数据包缺 buffer_min/origin_transfer_min/dest_transfer_min 时,
121
+ // 这里的 `Number(undefined)` = NaN 会一路流到求解层。NaN **不在 parse 边界补 0**
122
+ // (补零等于替用户编接驳时间,ADR-10 翻译不造数),也不在这里整包拒收
123
+ // (data/yunnan-pack.json 的 yn0 真值按 meta.reconcil 仍待 founder 校准)。
124
+ // 防线在 solveUnifiedInner 的 assertZ3IntegerInputs:进 Z3 之前逐字段点名,
125
+ // 以显式 solver_error 返回,绝不冒充「不可行」。
126
+
120
127
  // ---- 适配器:旧候选用例(洱海形态:request + candidates)→ 单 choice 段 -------------
121
128
  // 与 py unified.py segments_from_candidate 逐行对齐
122
129
 
@@ -485,8 +492,20 @@ function releaseZ3(resource: { release?: () => void } | null | undefined): void
485
492
  if (typeof resource?.release === 'function') resource.release()
486
493
  }
487
494
 
488
- /** 航班链形态求解:按 Option 选择,锚点命名约束,core 剥竖线(D-2 修复) */
489
- export async function solveUnified(spec: JourneySpecTS): Promise<{
495
+ /**
496
+ * Issue #620:求解器自身失败的显式类别。**不是 unsat_core**——判定缺席,不是「不可行」。
497
+ * 机器 token 只住在 `code` 里,不得渲染进任何用户面「冲突」文案。
498
+ */
499
+ export type SolverErrorInfo = {
500
+ /** `solver_input_not_integer` = spec 里有非整数进不了 Z3 整数编码(确定性,可定位到字段);
501
+ * `solver_runtime_error` = z3-solver / WASM 运行时异常(含真正的内存/竞态形态)。 */
502
+ code: 'solver_input_not_integer' | 'solver_runtime_error'
503
+ message: string
504
+ }
505
+
506
+ export interface UnifiedSolveResult {
507
+ /** `solver_error` 存在时,`feasible: false` 只表示「没有产出可行解」,
508
+ * **不表示「不可行」**——那一轮根本没有判定。读者必须先看 `solver_error`。 */
490
509
  feasible: boolean
491
510
  money_cny?: number
492
511
  legs?: Array<LegReport & { leg: string; d2d_min?: number }>
@@ -495,27 +514,76 @@ export async function solveUnified(spec: JourneySpecTS): Promise<{
495
514
  suggestions?: Array<{ relax: string; money_cny: number }>
496
515
  work_window_exclusions?: Array<{ segment: string; option: string; reason: string }>
497
516
  skeleton_notes?: string[]
498
- }> {
499
- // WASM 防护:如果 z3-solver 加载或求解触发 memory access 错误,不让异常穿透到进程层把 dsh 杀掉。
500
- // 候选形态走 solveChoiceSegment 不经过这里——这里是显式航班链路径,用户量较少。
517
+ solver_error?: SolverErrorInfo
518
+ }
519
+
520
+ /** Issue #620:进 Z3 前的输入类型闸。`Int.val(NaN|Infinity|小数)` 在 WASM 里是
521
+ * `Assertion failed`,不是一个可解释的判定——所以在构造 AST 之前抛可定位的类型化错误。 */
522
+ export class SolverInputError extends Error {
523
+ readonly code = 'solver_input_not_integer' as const
524
+ constructor(message: string) {
525
+ super(message)
526
+ this.name = 'SolverInputError'
527
+ }
528
+ }
529
+
530
+ /** 把所有将要变成 `Int.val(...)` 的数验成有限整数;违例逐项点名(段/候选/字段)。 */
531
+ function assertZ3IntegerInputs(spec: JourneySpecTS, wakeFloorMin: number): void {
532
+ const bad: string[] = []
533
+ const check = (where: string, value: unknown): void => {
534
+ // NaN/Infinity 经 JSON.stringify 会变成 "null",诊断信息就丢了——数值一律按原文印。
535
+ if (!Number.isInteger(value)) bad.push(`${where}=${typeof value === 'number' ? String(value) : JSON.stringify(value)}`)
536
+ }
537
+ check('wakeFloorMin', wakeFloorMin)
538
+ if (spec.budgetCny !== undefined) check('budgetCny', spec.budgetCny)
539
+ for (const seg of spec.segments) {
540
+ if (seg.anchors?.arriveByMin !== undefined) check(`${seg.id}.anchors.arriveByMin`, seg.anchors.arriveByMin)
541
+ if (seg.anchors?.departAfterMin !== undefined) check(`${seg.id}.anchors.departAfterMin`, seg.anchors.departAfterMin)
542
+ // 段级 wake/arriveStay 编码只取 options[0].move 的接驳口径(见下方 o0)。
543
+ const o0 = seg.options[0]?.move
544
+ if (o0) {
545
+ check(`${seg.id}.bufferMin`, o0.bufferMin)
546
+ check(`${seg.id}.originTransferMin`, o0.originTransferMin)
547
+ check(`${seg.id}.destTransferMin`, o0.destTransferMin)
548
+ }
549
+ for (const opt of seg.options) {
550
+ const svc = opt.move?.services[0]
551
+ if (!svc) {
552
+ bad.push(`${seg.id}/${opt.id}.services[0]=missing`)
553
+ continue
554
+ }
555
+ check(`${seg.id}/${opt.id}.depMin`, svc.depMin)
556
+ check(`${seg.id}/${opt.id}.arrMin`, svc.arrMin)
557
+ check(`${seg.id}/${opt.id}.priceCny`, svc.priceCny)
558
+ }
559
+ }
560
+ if (bad.length > 0) {
561
+ throw new SolverInputError(
562
+ `solveUnified: Z3 整数编码只接受有限整数,下列输入不是(issue #620):${bad.slice(0, 8).join('、')}`
563
+ + (bad.length > 8 ? `(共 ${bad.length} 处)` : ''),
564
+ )
565
+ }
566
+ }
567
+
568
+ /** 航班链形态求解:按 Option 选择,锚点命名约束,core 剥竖线(D-2 修复) */
569
+ export async function solveUnified(spec: JourneySpecTS): Promise<UnifiedSolveResult> {
570
+ // 求解器失败防护:z3-solver 加载/求解异常不让它穿透到进程层把 dsh 杀掉。
571
+ // 但也**不冒充判定**(issue #620):崩溃以 solver_error 显式返回,unsat_core 保持缺席,
572
+ // 否则「引擎故障」会被下游当成「你的行程不可行」,理由位置上还放着机器 token。
573
+ // 候选形态走 solveChoiceSegment 不经过这里——这里是显式航班链路径。
501
574
  try {
502
575
  return await solveUnifiedInner(spec)
503
576
  } catch (e) {
504
- console.error('[gotry] solveUnified failed (likely wasm thread race):', (e as Error).message?.slice(0, 200))
505
- return { feasible: false, unsat_core: ['wasm_runtime_error'], red_flags: ['WASM 求解器异常,建议下次用候选形态(枚举)重试'] }
577
+ const err = e instanceof Error ? e : new Error(String(e))
578
+ const code: SolverErrorInfo['code'] = err instanceof SolverInputError ? err.code : 'solver_runtime_error'
579
+ const message = (err.message ?? '').slice(0, 200)
580
+ // 措辞纪律:不再写「likely wasm thread race」——#620 证明它也可能是确定性的输入错误。
581
+ console.error(`[gotry] solveUnified aborted — ${code}(求解器失败,不是「不可行」判定):`, message)
582
+ return { feasible: false, solver_error: { code, message } }
506
583
  }
507
584
  }
508
585
 
509
- async function solveUnifiedInner(spec: JourneySpecTS): Promise<{
510
- feasible: boolean
511
- money_cny?: number
512
- legs?: Array<LegReport & { leg: string; d2d_min?: number }>
513
- red_flags?: string[]
514
- unsat_core?: string[]
515
- suggestions?: Array<{ relax: string; money_cny: number }>
516
- work_window_exclusions?: Array<{ segment: string; option: string; reason: string }>
517
- skeleton_notes?: string[]
518
- }> {
586
+ async function solveUnifiedInner(spec: JourneySpecTS): Promise<UnifiedSolveResult> {
519
587
  // M-1:求解前的工作窗口确定性预过滤(与 py 对齐),排除理由入记录
520
588
  // 骨架层(§7-1):三值语义标注——枢纽间否定只降权不排除(骨架滞后会错杀 EK329)
521
589
  const skeletonNotes: string[] = []
@@ -541,11 +609,16 @@ async function solveUnifiedInner(spec: JourneySpecTS): Promise<{
541
609
  }
542
610
  }
543
611
 
612
+ // Issue #620:类型闸在 Z3 会话之外先跑——非整数输入不许进 WASM(进去就是
613
+ // `Assertion failed`,还会污染共享实例)。抛 SolverInputError,由 solveUnified 归类。
614
+ const wakeFloorPre = spec.defaultWakeFloorMin ?? hhmmToMin('06:00')
615
+ assertZ3IntegerInputs(spec, wakeFloorPre)
616
+
544
617
  // Z3 会话进互斥门(骨架/预过滤等网络段留在门外):solveUnifiedInner 的判定段
545
618
  // 从 here 到 return 全部独占共享实例。
546
619
  return withZ3('unified.solveUnifiedInner', async z3 => {
547
620
  const { Bool, If, Int, Solver, Sum } = z3
548
- const wakeFloor = spec.defaultWakeFloorMin ?? hhmmToMin('06:00')
621
+ const wakeFloor = wakeFloorPre
549
622
 
550
623
  const allSels: Record<string, any[]> = {}
551
624
  const assertions: Record<string, any> = {}